feat: better configuration

This commit is contained in:
leonardotrapani
2026-01-31 22:37:58 +01:00
parent 077e08b887
commit f251df302d
21 changed files with 2972 additions and 2906 deletions
+32 -1175
View File
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
package tui
import (
"fmt"
"strconv"
"time"
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/config"
)
// AdvancedSection represents a section in the advanced settings menu
type AdvancedSection string
const (
AdvancedRecording AdvancedSection = "recording"
AdvancedInjectionTimeout AdvancedSection = "injection_timeout"
AdvancedBack AdvancedSection = "back"
)
// editAdvanced handles the advanced settings submenu
func editAdvanced(cfg *config.Config) error {
for {
options := []huh.Option[AdvancedSection]{
huh.NewOption(formatAdvancedRecordingLabel(cfg), AdvancedRecording),
huh.NewOption(formatAdvancedInjectionTimeoutLabel(cfg), AdvancedInjectionTimeout),
huh.NewOption("Back to Main Menu", AdvancedBack),
}
var selected AdvancedSection
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[AdvancedSection]().
Title("Advanced Settings").
Description("Configure low-level options").
Options(options...).
Value(&selected),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return err
}
switch selected {
case AdvancedBack:
return nil
case AdvancedRecording:
if err := editRecording(cfg); err != nil {
continue
}
case AdvancedInjectionTimeout:
if err := editInjectionTimeouts(cfg); err != nil {
continue
}
}
}
}
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)
}
// editRecording handles the recording settings
func editRecording(cfg *config.Config) error {
sampleRate := strconv.Itoa(cfg.Recording.SampleRate)
channels := strconv.Itoa(cfg.Recording.Channels)
format := cfg.Recording.Format
bufferSize := strconv.Itoa(cfg.Recording.BufferSize)
device := cfg.Recording.Device
channelBufferSize := strconv.Itoa(cfg.Recording.ChannelBufferSize)
timeout := cfg.Recording.Timeout.String()
channelOptions := []huh.Option[string]{
huh.NewOption("1 (Mono) - Recommended", "1"),
huh.NewOption("2 (Stereo)", "2"),
}
formatOptions := []huh.Option[string]{
huh.NewOption("s16 (16-bit signed) - Recommended", "s16"),
huh.NewOption("f32 (32-bit float)", "f32"),
}
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Sample Rate (Hz)").
Description("Audio sample rate. 16000 is optimal for speech recognition.").
Placeholder("16000").
Value(&sampleRate).
Validate(func(s string) error {
if _, err := strconv.Atoi(s); err != nil {
return fmt.Errorf("must be a number")
}
return nil
}),
huh.NewSelect[string]().
Title("Channels").
Description("Number of audio channels").
Options(channelOptions...).
Value(&channels),
huh.NewSelect[string]().
Title("Audio Format").
Description("Sample format").
Options(formatOptions...).
Value(&format),
),
huh.NewGroup(
huh.NewInput().
Title("Buffer Size (bytes)").
Description("Internal buffer size. Larger = less CPU, more latency.").
Placeholder("8192").
Value(&bufferSize).
Validate(func(s string) error {
if _, err := strconv.Atoi(s); err != nil {
return fmt.Errorf("must be a number")
}
return nil
}),
huh.NewInput().
Title("Channel Buffer Size").
Description("Number of audio frames to buffer.").
Placeholder("30").
Value(&channelBufferSize).
Validate(func(s string) error {
if _, err := strconv.Atoi(s); err != nil {
return fmt.Errorf("must be a number")
}
return nil
}),
),
huh.NewGroup(
huh.NewInput().
Title("Device").
Description("PipeWire device name. Empty = default microphone.").
Placeholder("(default)").
Value(&device),
huh.NewInput().
Title("Recording Timeout").
Description("Max recording duration (e.g., '30s', '2m', '5m'). Prevents runaway recordings.").
Placeholder("5m").
Value(&timeout).
Validate(func(s string) error {
if _, err := time.ParseDuration(s); err != nil {
return fmt.Errorf("invalid duration format (use '30s', '2m', etc.)")
}
return nil
}),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return err
}
cfg.Recording.SampleRate, _ = strconv.Atoi(sampleRate)
cfg.Recording.Channels, _ = strconv.Atoi(channels)
cfg.Recording.Format = format
cfg.Recording.BufferSize, _ = strconv.Atoi(bufferSize)
cfg.Recording.Device = device
cfg.Recording.ChannelBufferSize, _ = strconv.Atoi(channelBufferSize)
cfg.Recording.Timeout, _ = time.ParseDuration(timeout)
return nil
}
// editInjectionTimeouts handles the injection timeout settings
func editInjectionTimeouts(cfg *config.Config) error {
ydotoolTimeout := cfg.Injection.YdotoolTimeout.String()
wtypeTimeout := cfg.Injection.WtypeTimeout.String()
clipboardTimeout := cfg.Injection.ClipboardTimeout.String()
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("ydotool Timeout").
Description("Timeout for ydotool commands (e.g., '5s', '10s')").
Placeholder("5s").
Value(&ydotoolTimeout).
Validate(func(s string) error {
if _, err := time.ParseDuration(s); err != nil {
return fmt.Errorf("invalid duration format")
}
return nil
}),
huh.NewInput().
Title("wtype Timeout").
Description("Timeout for wtype commands (e.g., '5s', '10s')").
Placeholder("5s").
Value(&wtypeTimeout).
Validate(func(s string) error {
if _, err := time.ParseDuration(s); err != nil {
return fmt.Errorf("invalid duration format")
}
return nil
}),
huh.NewInput().
Title("Clipboard Timeout").
Description("Timeout for clipboard operations (e.g., '3s', '5s')").
Placeholder("3s").
Value(&clipboardTimeout).
Validate(func(s string) error {
if _, err := time.ParseDuration(s); err != nil {
return fmt.Errorf("invalid duration format")
}
return nil
}),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return err
}
cfg.Injection.YdotoolTimeout, _ = time.ParseDuration(ydotoolTimeout)
cfg.Injection.WtypeTimeout, _ = time.ParseDuration(wtypeTimeout)
cfg.Injection.ClipboardTimeout, _ = time.ParseDuration(clipboardTimeout)
return nil
}
+109
View File
@@ -0,0 +1,109 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/config"
)
// formatProvidersLabel formats the providers menu option
func formatProvidersLabel(cfg *config.Config) string {
return "Providers"
}
// formatTranscriptionLabel formats the transcription menu option
func formatTranscriptionLabel(cfg *config.Config) string {
return "Transcription"
}
// formatLLMLabel formats the LLM menu option
func formatLLMLabel(cfg *config.Config) string {
return "LLM"
}
// formatKeywordsLabel formats the keywords menu option
func formatKeywordsLabel(cfg *config.Config) string {
return "Keywords"
}
// formatInjectionLabel formats the injection menu option
func formatInjectionLabel(cfg *config.Config) string {
return "Injection"
}
// formatNotificationsLabel formats the notifications menu option
func formatNotificationsLabel(cfg *config.Config) string {
return "Notifications"
}
func showSummary(cfg *config.Config) (bool, error) {
fmt.Println()
fmt.Println(StyleHeader.Render("Configuration Summary"))
fmt.Println()
var providers []string
for name := range cfg.Providers {
providers = append(providers, name)
}
fmt.Printf(" %s %s\n", StyleLabel.Render("Providers:"), strings.Join(providers, ", "))
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)
}
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:"))
}
if len(cfg.Keywords) > 0 {
fmt.Printf(" %s %s\n", StyleLabel.Render("Keywords:"), strings.Join(cfg.Keywords, ", "))
}
fmt.Printf(" %s %s\n", StyleLabel.Render("Backends:"), strings.Join(cfg.Injection.Backends, " -> "))
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
}
+444
View File
@@ -0,0 +1,444 @@
package tui
import (
"fmt"
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// editLLM handles the LLM section edit with smart provider detection
func editLLM(cfg *config.Config, configuredProviders []string) ([]string, error) {
var llmProviders []string
for _, name := range configuredProviders {
p := provider.GetProvider(name)
if p != nil && p.SupportsLLM() {
llmProviders = append(llmProviders, name)
}
}
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
enableLLM := cfg.LLM.Enabled
enableDesc := "LLM improves transcription by fixing grammar, removing stutters, and cleaning up text"
if cfg.LLM.Enabled {
enableDesc = fmt.Sprintf("Currently: enabled (%s/%s). %s", cfg.LLM.Provider, cfg.LLM.Model, enableDesc)
} else {
enableDesc = "Currently: disabled. " + enableDesc
}
enableForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Enable LLM Post-Processing? (Recommended)").
Description(enableDesc).
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
}
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"))
}
}
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
}
llmProviderDesc := "Choose which service to use for text post-processing"
if cfg.LLM.Provider != "" {
llmProviderDesc = fmt.Sprintf("Currently: %s/%s", cfg.LLM.Provider, cfg.LLM.Model)
}
providerForm := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("LLM Provider").
Description(llmProviderDesc).
Options(llmOptions...).
Value(&selectedProvider),
),
).WithTheme(getTheme())
if err := providerForm.Run(); err != nil {
return configuredProviders, err
}
configuredProviders = ensureProviderConfigured(cfg, selectedProvider, configuredProviders)
cfg.LLM.Provider = selectedProvider
modelOptions := getLLMModelOptions(selectedProvider)
selectedModel := cfg.LLM.Model
if selectedModel == "" && len(modelOptions) > 0 {
selectedModel = modelOptions[0].Value
}
llmModelDesc := ""
if cfg.LLM.Model != "" {
llmModelDesc = fmt.Sprintf("Currently: %s", cfg.LLM.Model)
}
modelForm := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("LLM Model").
Description(llmModelDesc).
Options(modelOptions...).
Value(&selectedModel),
),
).WithTheme(getTheme())
if err := modelForm.Run(); err != nil {
return configuredProviders, err
}
cfg.LLM.Model = selectedModel
var ppErr error
postProcessing, ppErr = selectPostProcessingOptions(postProcessing)
if ppErr != nil {
return configuredProviders, ppErr
}
cfg.LLM.PostProcessing = postProcessing
enableCustomPrompt := customPrompt.Enabled
customPromptText := customPrompt.Prompt
customPromptDesc := "Add extra instructions for the LLM"
if customPrompt.Enabled && customPrompt.Prompt != "" {
preview := customPrompt.Prompt
if len(preview) > 40 {
preview = preview[:40] + "..."
}
customPromptDesc = fmt.Sprintf("Currently: \"%s\"", preview)
} else {
customPromptDesc = "Currently: none. " + customPromptDesc
}
customForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Add custom prompt?").
Description(customPromptDesc).
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
}
// 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 (not configured)", "openai"))
}
if !configured["groq"] {
options = append(options, huh.NewOption("Groq Llama (not configured)", "groq"))
}
return options
}
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),
}
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
}
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 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
}
+251
View File
@@ -0,0 +1,251 @@
package tui
import (
"fmt"
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/notify"
)
// editNotifications handles the notifications section edit with type and custom messages
func editNotifications(cfg *config.Config) error {
enabled := cfg.Notifications.Enabled
desc := "Show notifications for recording status changes"
if cfg.Notifications.Enabled {
desc = fmt.Sprintf("Currently: enabled (%s). %s", cfg.Notifications.Type, desc)
} else {
desc = "Currently: disabled. " + desc
}
enableForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Enable desktop notifications?").
Description(desc).
Value(&enabled),
),
).WithTheme(getTheme())
if err := enableForm.Run(); err != nil {
return err
}
cfg.Notifications.Enabled = enabled
if !enabled {
return nil
}
notifType := cfg.Notifications.Type
if notifType == "" {
notifType = "desktop"
}
typeOptions := []huh.Option[string]{
huh.NewOption("Desktop notifications (notify-send)", "desktop"),
huh.NewOption("Log to console only", "log"),
huh.NewOption("None (silent)", "none"),
}
typeForm := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Notification Type").
Description("How should notifications be displayed?").
Options(typeOptions...).
Value(&notifType),
),
).WithTheme(getTheme())
if err := typeForm.Run(); err != nil {
return err
}
cfg.Notifications.Type = notifType
var configureMessages bool
msgForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Configure custom notification messages?").
Description("Customize the text shown in notifications").
Affirmative("Yes").
Negative("No, use defaults").
Value(&configureMessages),
),
).WithTheme(getTheme())
if err := msgForm.Run(); err != nil {
return err
}
if configureMessages {
if err := editNotificationMessages(cfg); err != nil {
return err
}
}
return nil
}
// editNotificationMessages allows editing individual notification messages
func editNotificationMessages(cfg *config.Config) error {
for {
var options []huh.Option[string]
for _, def := range notify.MessageDefs {
currentBody := def.DefaultBody
switch def.ConfigKey {
case "recording_started":
if cfg.Notifications.Messages.RecordingStarted.Body != "" {
currentBody = cfg.Notifications.Messages.RecordingStarted.Body
}
case "transcribing":
if cfg.Notifications.Messages.Transcribing.Body != "" {
currentBody = cfg.Notifications.Messages.Transcribing.Body
}
case "llm_processing":
if cfg.Notifications.Messages.LLMProcessing.Body != "" {
currentBody = cfg.Notifications.Messages.LLMProcessing.Body
}
case "config_reloaded":
if cfg.Notifications.Messages.ConfigReloaded.Body != "" {
currentBody = cfg.Notifications.Messages.ConfigReloaded.Body
}
case "operation_cancelled":
if cfg.Notifications.Messages.OperationCancelled.Body != "" {
currentBody = cfg.Notifications.Messages.OperationCancelled.Body
}
case "recording_aborted":
if cfg.Notifications.Messages.RecordingAborted.Body != "" {
currentBody = cfg.Notifications.Messages.RecordingAborted.Body
}
case "injection_aborted":
if cfg.Notifications.Messages.InjectionAborted.Body != "" {
currentBody = cfg.Notifications.Messages.InjectionAborted.Body
}
}
displayBody := currentBody
if len(displayBody) > 30 {
displayBody = displayBody[:30] + "..."
}
label := fmt.Sprintf("%s: \"%s\"", def.ConfigKey, displayBody)
options = append(options, huh.NewOption(label, def.ConfigKey))
}
options = append(options, huh.NewOption("Back", "back"))
var selected string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Notification Messages").
Description("Select a message to edit").
Options(options...).
Value(&selected),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return err
}
if selected == "back" {
return nil
}
if err := editSingleMessage(cfg, selected); err != nil {
continue
}
}
}
// editSingleMessage edits a single notification message
func editSingleMessage(cfg *config.Config, configKey string) error {
var def notify.MessageDef
for _, d := range notify.MessageDefs {
if d.ConfigKey == configKey {
def = d
break
}
}
var currentTitle, currentBody string
switch configKey {
case "recording_started":
currentTitle = cfg.Notifications.Messages.RecordingStarted.Title
currentBody = cfg.Notifications.Messages.RecordingStarted.Body
case "transcribing":
currentTitle = cfg.Notifications.Messages.Transcribing.Title
currentBody = cfg.Notifications.Messages.Transcribing.Body
case "llm_processing":
currentTitle = cfg.Notifications.Messages.LLMProcessing.Title
currentBody = cfg.Notifications.Messages.LLMProcessing.Body
case "config_reloaded":
currentTitle = cfg.Notifications.Messages.ConfigReloaded.Title
currentBody = cfg.Notifications.Messages.ConfigReloaded.Body
case "operation_cancelled":
currentTitle = cfg.Notifications.Messages.OperationCancelled.Title
currentBody = cfg.Notifications.Messages.OperationCancelled.Body
case "recording_aborted":
currentTitle = cfg.Notifications.Messages.RecordingAborted.Title
currentBody = cfg.Notifications.Messages.RecordingAborted.Body
case "injection_aborted":
currentTitle = cfg.Notifications.Messages.InjectionAborted.Title
currentBody = cfg.Notifications.Messages.InjectionAborted.Body
}
if currentTitle == "" {
currentTitle = def.DefaultTitle
}
if currentBody == "" {
currentBody = def.DefaultBody
}
title := currentTitle
body := currentBody
var fields []huh.Field
if !def.IsError {
fields = append(fields, huh.NewInput().
Title("Title").
Description(fmt.Sprintf("Default: %s", def.DefaultTitle)).
Placeholder(def.DefaultTitle).
Value(&title))
}
fields = append(fields, huh.NewInput().
Title("Body").
Description(fmt.Sprintf("Default: %s", def.DefaultBody)).
Placeholder(def.DefaultBody).
Value(&body))
form := huh.NewForm(
huh.NewGroup(fields...),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return err
}
msgConfig := config.MessageConfig{Title: title, Body: body}
switch configKey {
case "recording_started":
cfg.Notifications.Messages.RecordingStarted = msgConfig
case "transcribing":
cfg.Notifications.Messages.Transcribing = msgConfig
case "llm_processing":
cfg.Notifications.Messages.LLMProcessing = msgConfig
case "config_reloaded":
cfg.Notifications.Messages.ConfigReloaded = msgConfig
case "operation_cancelled":
cfg.Notifications.Messages.OperationCancelled = msgConfig
case "recording_aborted":
cfg.Notifications.Messages.RecordingAborted = msgConfig
case "injection_aborted":
cfg.Notifications.Messages.InjectionAborted = msgConfig
}
return nil
}
+203
View File
@@ -0,0 +1,203 @@
package tui
import (
"fmt"
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// getProviderDisplayName returns the display name for a provider
func getProviderDisplayName(providerName string) string {
if name, ok := providerDisplayNames[providerName]; ok {
return name
}
return providerName
}
// maskAPIKey returns a masked version of an API key for display
func maskAPIKey(key string) string {
if len(key) <= 8 {
return "***"
}
return key[:7] + "..." + key[len(key)-4:]
}
// 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 {
for {
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
}
apiKey, err := configureSingleProvider(cfg, selected)
if err != nil {
continue
}
if apiKey != "" {
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)
}
}
// configureSingleProvider handles the complete flow for configuring a single provider's API key.
// Shows confirm dialog if key exists, then prompts for new key if needed.
// Returns the new API key (empty if user kept current) and any error.
func configureSingleProvider(cfg *config.Config, providerName string) (string, error) {
var existingKey string
if pc, exists := cfg.Providers[providerName]; exists && pc.APIKey != "" {
existingKey = pc.APIKey
}
if existingKey != "" {
displayName := getProviderDisplayName(providerName)
masked := maskAPIKey(existingKey)
var update bool
confirmForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(fmt.Sprintf("%s API Key", displayName)).
Description(fmt.Sprintf("Current: %s", masked)).
Affirmative("Update key").
Negative("Keep current").
Value(&update),
),
).WithTheme(getTheme())
if err := confirmForm.Run(); err != nil {
return "", err
}
if !update {
return "", nil
}
}
return inputAPIKey(providerName)
}
func inputAPIKey(providerName string) (string, error) {
p := provider.GetProvider(providerName)
displayName := getProviderDisplayName(providerName)
if p != nil {
if name, ok := providerDisplayNames[p.Name()]; ok {
displayName = 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
}
// ensureProviderConfigured prompts for API key if provider not configured
func ensureProviderConfigured(cfg *config.Config, selectedProvider string, configuredProviders []string) []string {
providerName := selectedProvider
switch selectedProvider {
case "groq-transcription", "groq-translation":
providerName = "groq"
case "mistral-transcription":
providerName = "mistral"
}
for _, p := range configuredProviders {
if p == providerName {
return configuredProviders
}
}
apiKey, err := configureSingleProvider(cfg, providerName)
if err != nil || apiKey == "" {
return configuredProviders
}
if cfg.Providers == nil {
cfg.Providers = make(map[string]config.ProviderConfig)
}
cfg.Providers[providerName] = config.ProviderConfig{APIKey: apiKey}
return append(configuredProviders, providerName)
}
+249
View File
@@ -0,0 +1,249 @@
package tui
import (
"fmt"
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// editTranscription handles the transcription section edit with smart provider detection
func editTranscription(cfg *config.Config, configuredProviders []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"))
}
}
}
unconfiguredOptions := getUnconfiguredTranscriptionOptions(configuredProviders)
if len(unconfiguredOptions) > 0 {
transcriptionOptions = append(transcriptionOptions, unconfiguredOptions...)
}
if len(transcriptionOptions) == 0 {
return configuredProviders, fmt.Errorf("no transcription providers available")
}
selectedProvider := cfg.Transcription.Provider
if selectedProvider == "" && len(transcriptionOptions) > 0 {
selectedProvider = transcriptionOptions[0].Value
}
providerDesc := "Choose which service to use for speech-to-text"
if cfg.Transcription.Provider != "" {
providerDesc = fmt.Sprintf("Currently: %s/%s", cfg.Transcription.Provider, cfg.Transcription.Model)
}
providerForm := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Transcription Provider").
Description(providerDesc).
Options(transcriptionOptions...).
Value(&selectedProvider),
),
).WithTheme(getTheme())
if err := providerForm.Run(); err != nil {
return configuredProviders, err
}
configuredProviders = ensureProviderConfigured(cfg, selectedProvider, configuredProviders)
cfg.Transcription.Provider = selectedProvider
modelOptions := getTranscriptionModelOptions(selectedProvider)
selectedModel := cfg.Transcription.Model
if selectedModel == "" && len(modelOptions) > 0 {
selectedModel = modelOptions[0].Value
}
modelDesc := ""
if cfg.Transcription.Model != "" {
modelDesc = fmt.Sprintf("Currently: %s", cfg.Transcription.Model)
}
language := cfg.Transcription.Language
langDesc := "ISO-639-1 code (e.g., 'en', 'es', 'fr') or empty for auto-detect"
if cfg.Transcription.Language != "" {
langDesc = fmt.Sprintf("Currently: %s. %s", cfg.Transcription.Language, langDesc)
}
modelForm := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Transcription Model").
Description(modelDesc).
Options(modelOptions...).
Value(&selectedModel),
huh.NewInput().
Title("Language").
Description(langDesc).
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
}
// 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 (not configured)", "openai"))
}
if !configured["groq"] {
options = append(options,
huh.NewOption("Groq Whisper transcription (not configured)", "groq-transcription"),
huh.NewOption("Groq Whisper translation (not configured)", "groq-translation"))
}
if !configured["mistral"] {
options = append(options, huh.NewOption("Mistral Voxtral (not configured)", "mistral-transcription"))
}
if !configured["elevenlabs"] {
options = append(options, huh.NewOption("ElevenLabs Scribe (not configured)", "elevenlabs"))
}
return options
}
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 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
}
+213
View File
@@ -0,0 +1,213 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/config"
)
// runFreshInstall runs the full configuration wizard for fresh installs
func runFreshInstall(cfg *config.Config) (*ConfigureResult, error) {
fmt.Println(Logo())
fmt.Println()
fmt.Println(StyleMuted.Render("Voice-powered typing for Wayland/Hyprland"))
fmt.Println()
selectedProviders, err := selectProviders()
if err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
if len(selectedProviders) == 0 {
return &ConfigureResult{Cancelled: true}, fmt.Errorf("no providers selected")
}
if cfg.Providers == nil {
cfg.Providers = make(map[string]config.ProviderConfig)
}
for _, providerName := range selectedProviders {
apiKey, err := inputAPIKey(providerName)
if err != 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
keywords, err := inputKeywords(cfg.Keywords)
if err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
cfg.Keywords = keywords
backends, err := selectBackends(cfg.Injection.Backends)
if err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
cfg.Injection.Backends = backends
notificationsEnabled, err := configureNotifications(cfg.Notifications.Enabled)
if err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
cfg.Notifications.Enabled = notificationsEnabled
confirmed, err := showSummary(cfg)
if err != nil || !confirmed {
return &ConfigureResult{Cancelled: true}, 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) {
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
}
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
desc := "Show notifications for recording status changes"
if existingEnabled {
desc = "Currently: enabled. " + desc
} else {
desc = "Currently: disabled. " + desc
}
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Enable desktop notifications?").
Description(desc).
Value(&enabled),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return false, err
}
return enabled, nil
}