feat: use new ui bubbletea

This commit is contained in:
leonardotrapani
2026-02-01 20:40:17 +01:00
parent 3ae86f7bae
commit 0b0e084155
25 changed files with 2249 additions and 1934 deletions
-204
View File
@@ -1,204 +0,0 @@
package tui
import (
"fmt"
"os"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/muesli/termenv"
)
// ConfigureResult holds the configuration result from the TUI
type ConfigureResult struct {
Config *config.Config
Cancelled bool
}
// AllProviders is the list of all supported cloud providers (require API keys)
var AllProviders = []string{"openai", "groq", "mistral", "elevenlabs", "deepgram"}
// LocalProviders is the list of local providers (no API key required)
var LocalProviders = []string{"whisper-cpp"}
// providerDisplayNames maps provider IDs to human-readable names
var providerDisplayNames = map[string]string{
"openai": "OpenAI",
"groq": "Groq",
"mistral": "Mistral",
"elevenlabs": "ElevenLabs",
"deepgram": "Deepgram",
"whisper-cpp": "Whisper.cpp (local)",
}
// 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"
SectionAdvanced ConfigSection = "advanced"
SectionSaveExit ConfigSection = "save_exit"
SectionDiscardExit ConfigSection = "discard_exit"
)
// Run starts the TUI configuration wizard
// If onboarding is true, forces the guided wizard flow even if config exists
func Run(existingConfig *config.Config, onboarding bool) (*ConfigureResult, error) {
if !onboarding && existingConfig != nil && hasUserChanges(existingConfig) {
return runEditExisting(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
func hasUserChanges(cfg *config.Config) bool {
if len(cfg.Providers) > 0 {
return true
}
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()
configuredProviders := getConfiguredProviders(cfg)
for {
clearScreen()
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
}
case SectionDiscardExit:
return &ConfigureResult{Cancelled: true}, nil
case SectionProviders:
if err := editProviders(cfg, false); err != nil {
continue
}
configuredProviders = getConfiguredProviders(cfg)
case SectionTranscription:
var err error
configuredProviders, err = editTranscription(cfg, configuredProviders)
if err != nil {
continue
}
case SectionLLM:
var err error
configuredProviders, err = editLLM(cfg, configuredProviders)
if err != nil {
continue
}
case SectionKeywords:
keywords, err := inputKeywords(cfg.Keywords)
if err != nil {
continue
}
cfg.Keywords = keywords
case SectionInjection:
backends, err := selectBackends(cfg.Injection.Backends)
if err != nil {
continue
}
cfg.Injection.Backends = backends
case SectionNotifications:
if err := editNotifications(cfg); err != nil {
continue
}
case SectionAdvanced:
if err := editAdvanced(cfg, false); err != nil {
continue
}
}
}
}
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("Advanced Settings", SectionAdvanced),
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
}
// clearScreen clears the terminal screen
func clearScreen() {
output := termenv.NewOutput(os.Stdout)
output.ClearScreen()
}
func getTheme() *huh.Theme {
t := huh.ThemeBase()
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)
t.Blurred.Title = lipgloss.NewStyle().Foreground(ColorMuted)
t.Blurred.Description = lipgloss.NewStyle().Foreground(ColorSubtle)
return t
}
-229
View File
@@ -1,229 +0,0 @@
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, onboarding bool) error {
exitLabel := "Done"
if onboarding {
exitLabel = "Next"
}
for {
options := []huh.Option[AdvancedSection]{
huh.NewOption(formatAdvancedRecordingLabel(cfg), AdvancedRecording),
huh.NewOption(formatAdvancedInjectionTimeoutLabel(cfg), AdvancedInjectionTimeout),
huh.NewOption(exitLabel, 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
}
-110
View File
@@ -1,110 +0,0 @@
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, ", "))
lang := cfg.Transcription.Language
if lang == "" {
lang = "auto-detect"
}
fmt.Printf(" %s %s/%s (%s)\n", StyleLabel.Render("Transcription:"), cfg.Transcription.Provider, cfg.Transcription.Model, lang)
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
}
-295
View File
@@ -1,295 +0,0 @@
package tui
import (
"fmt"
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// buildLLMModelLabel creates the display label for an LLM model option
func buildLLMModelLabel(m provider.Model) string {
return fmt.Sprintf("%s (%s)", m.Name, m.Description)
}
// 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 && len(provider.ModelsOfType(p, provider.LLM)) > 0 {
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. Recommended for weak voice models"
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(providerName string) []huh.Option[string] {
p := provider.GetProvider(providerName)
if p == nil {
return []huh.Option[string]{}
}
models := provider.ModelsOfType(p, provider.LLM)
var options []huh.Option[string]
for _, m := range models {
label := buildLLMModelLabel(m)
options = append(options, huh.NewOption(label, m.ID))
}
return options
}
// 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
}
-251
View File
@@ -1,251 +0,0 @@
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
}
-243
View File
@@ -1,243 +0,0 @@
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, onboarding bool) error {
exitLabel := "Done"
if onboarding {
exitLabel = "Next"
}
// track if we should default to "back" (Next) after configuring a provider
defaultToExit := false
for {
var options []huh.Option[string]
options = append(options, huh.NewOption("Local", "local"))
for _, name := range AllProviders {
options = append(options, huh.NewOption(formatProviderOption(cfg, name), name))
}
options = append(options, huh.NewOption(exitLabel, "back"))
selected := ""
if defaultToExit {
selected = "back"
}
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
}
if selected == "local" {
if err := showLocalProviderInfo(); err != nil {
continue
}
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}
defaultToExit = true
}
}
}
func showLocalProviderInfo() error {
selected := "done"
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Local Models").
Description("No need to configure any API keys for local models, go to the next step.").
Options(huh.NewOption("Done", "done")).
Value(&selected),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return err
}
return nil
}
// 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":
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)
}
-351
View File
@@ -1,351 +0,0 @@
package tui
import (
"context"
"fmt"
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/deps"
"github.com/leonardotrapani/hyprvoice/internal/models/whisper"
"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]
// add local provider first (whisper-cpp)
whisperStatus := deps.CheckWhisperCli()
if whisperStatus.Installed {
transcriptionOptions = append(transcriptionOptions,
huh.NewOption("Whisper.cpp (local, no API key)", "whisper-cpp"))
} else {
transcriptionOptions = append(transcriptionOptions,
huh.NewOption("Whisper.cpp (whisper-cli not found)", "whisper-cpp-disabled"))
}
// add configured cloud providers
for _, name := range configuredProviders {
p := provider.GetProvider(name)
if p != nil && len(provider.ModelsOfType(p, provider.Transcription)) > 0 {
switch name {
case "openai":
transcriptionOptions = append(transcriptionOptions,
huh.NewOption("OpenAI Whisper", "openai"))
case "groq":
transcriptionOptions = append(transcriptionOptions,
huh.NewOption("Groq Whisper", "groq-transcription"))
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
}
// handle disabled whisper-cpp selection
if selectedProvider == "whisper-cpp-disabled" {
fmt.Println()
fmt.Println(StyleWarning.Render("whisper-cli not found in PATH"))
fmt.Println(StyleMuted.Render("Install whisper.cpp to use local transcription:"))
fmt.Println(StyleMuted.Render(" https://github.com/ggerganov/whisper.cpp"))
fmt.Println()
var proceed bool
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Continue?").
Affirmative("Choose another provider").
Negative("Cancel").
Value(&proceed),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return configuredProviders, err
}
if proceed {
return editTranscription(cfg, configuredProviders)
}
return configuredProviders, nil
}
// local providers don't need API key configuration
if selectedProvider != "whisper-cpp" {
configuredProviders = ensureProviderConfigured(cfg, selectedProvider, configuredProviders)
}
cfg.Transcription.Provider = selectedProvider
modelOptions := getTranscriptionModelOptions(selectedProvider)
selectedModel := cfg.Transcription.Model
if selectedModel == "" && len(modelOptions) > 0 {
// skip header options (empty value) to find first real model
for _, opt := range modelOptions {
if opt.Value != "" {
selectedModel = opt.Value
break
}
}
}
modelDesc := ""
if cfg.Transcription.Model != "" {
modelDesc = fmt.Sprintf("Currently: %s", cfg.Transcription.Model)
}
modelForm := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Transcription Model").
Description(modelDesc).
Options(modelOptions...).
Value(&selectedModel),
),
).WithTheme(getTheme())
if err := modelForm.Run(); err != nil {
return configuredProviders, err
}
// if user selected a section header (empty value), re-prompt
if selectedModel == "" {
return editTranscription(cfg, configuredProviders)
}
registryName := mapConfigProviderToRegistry(selectedProvider)
// for whisper-cpp, check if model needs download
if selectedProvider == "whisper-cpp" && !whisper.IsInstalled(selectedModel) {
modelInfo := whisper.GetModel(selectedModel)
if modelInfo == nil {
return configuredProviders, fmt.Errorf("unknown model: %s", selectedModel)
}
var confirm bool
confirmForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(fmt.Sprintf("Download %s (%s)?", modelInfo.Name, modelInfo.Size)).
Description("Model is not installed. Download now?").
Affirmative("Download").
Negative("Cancel").
Value(&confirm),
),
).WithTheme(getTheme())
if err := confirmForm.Run(); err != nil {
return configuredProviders, err
}
if !confirm {
return configuredProviders, nil
}
// download with progress
fmt.Println()
fmt.Printf("Downloading %s...\n", modelInfo.Name)
lastPct := 0
err := whisper.Download(context.Background(), selectedModel, func(downloaded, total int64) {
if total > 0 {
pct := int(downloaded * 100 / total)
if pct >= lastPct+10 {
fmt.Printf(" %d%%\n", pct)
lastPct = pct
}
}
})
if err != nil {
fmt.Println(StyleError.Render(fmt.Sprintf("Download failed: %v", err)))
return configuredProviders, err
}
fmt.Println(StyleSuccess.Render(fmt.Sprintf("Downloaded %s", modelInfo.Name)))
fmt.Println()
}
cfg.Transcription.Model = selectedModel
// select language for this model
model, err := provider.GetModel(registryName, selectedModel)
if err != nil {
return configuredProviders, err
}
if cfg.Transcription.Language != "" && !model.SupportsLanguage(cfg.Transcription.Language) {
cfg.Transcription.Language = ""
}
if len(model.SupportedLanguages) <= 1 {
if len(model.SupportedLanguages) == 1 {
cfg.Transcription.Language = model.SupportedLanguages[0]
} else {
cfg.Transcription.Language = ""
}
} else {
languageOptions := getModelLanguageOptions(model, cfg.Transcription.Language)
selectedLanguage := cfg.Transcription.Language
languageForm := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Language").
Description("Select language for transcription").
Options(languageOptions...).
Filtering(true).
Value(&selectedLanguage),
),
).WithTheme(getTheme())
if err := languageForm.Run(); err != nil {
return configuredProviders, err
}
cfg.Transcription.Language = selectedLanguage
}
// set streaming mode based on model capabilities
if model.SupportsBothModes() {
useStreaming := cfg.Transcription.Streaming
streamingForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Enable streaming mode?").
Description("This model supports both batch and streaming modes").
Affirmative("Yes, use streaming (real-time)").
Negative("No, use batch (after recording)").
Value(&useStreaming),
),
).WithTheme(getTheme())
if err := streamingForm.Run(); err != nil {
return configuredProviders, err
}
cfg.Transcription.Streaming = useStreaming
} else if model.SupportsStreaming {
cfg.Transcription.Streaming = true
fmt.Println(StyleSuccess.Render("Streaming mode enabled (this model only supports streaming)"))
} else {
cfg.Transcription.Streaming = false
}
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 (not configured)", "groq-transcription"))
}
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(configProvider string) []huh.Option[string] {
// map config provider name to registry provider name
registryName := mapConfigProviderToRegistry(configProvider)
p := provider.GetProvider(registryName)
if p == nil {
return []huh.Option[string]{}
}
models := provider.ModelsOfType(p, provider.Transcription)
var options []huh.Option[string]
for _, m := range models {
label := buildModelLabel(m)
if m.Local && registryName == "whisper-cpp" {
if whisper.IsInstalled(m.ID) {
label = "[x] " + label
} else {
label = "[ ] " + label
}
}
options = append(options, huh.NewOption(label, m.ID))
}
return options
}
// mapConfigProviderToRegistry maps config provider names to registry provider names
func mapConfigProviderToRegistry(configProvider string) string {
switch configProvider {
case "groq-transcription":
return "groq"
case "mistral-transcription":
return "mistral"
default:
return configProvider
}
}
// buildModelLabel creates the display label for a model option
func buildModelLabel(m provider.Model) string {
label := fmt.Sprintf("%s (%s)", m.Name, m.Description)
// append size for local models
if m.Local && m.LocalInfo != nil {
label += fmt.Sprintf(" [%s]", m.LocalInfo.Size)
}
// append mode capabilities
if m.SupportsBothModes() {
label += " [batch+streaming]"
} else if m.SupportsStreaming {
label += " [streaming]"
}
return label
}
+14 -14
View File
@@ -18,20 +18,20 @@ func TestGetTranscriptionModelOptions_ShowsCapabilities(t *testing.T) {
// verify models show capability tags
for _, opt := range options {
model, _, _ := provider.FindModelByID(opt.Value)
model, _, _ := provider.FindModelByID(opt.ID)
if model == nil {
continue
}
if model.SupportsStreaming && !model.SupportsBatch {
// streaming-only should have [streaming] tag
if !strings.Contains(opt.Key, "[streaming]") {
t.Errorf("streaming-only model %s should have [streaming] tag in label: %s", opt.Value, opt.Key)
if !strings.Contains(opt.Label, "[streaming]") {
t.Errorf("streaming-only model %s should have [streaming] tag in label: %s", opt.ID, opt.Label)
}
} else if model.SupportsBothModes() {
// both modes should have [batch+streaming] tag
if !strings.Contains(opt.Key, "[batch+streaming]") {
t.Errorf("both-modes model %s should have [batch+streaming] tag in label: %s", opt.Value, opt.Key)
if !strings.Contains(opt.Label, "[batch+streaming]") {
t.Errorf("both-modes model %s should have [batch+streaming] tag in label: %s", opt.ID, opt.Label)
}
}
// batch-only models don't need a tag
@@ -43,8 +43,8 @@ func TestGetTranscriptionModelOptions_NoHeadersAnymore(t *testing.T) {
options := getTranscriptionModelOptions("elevenlabs")
for _, opt := range options {
if opt.Value == "" {
t.Errorf("should not have headers anymore, got: %s", opt.Key)
if opt.ID == "" {
t.Errorf("should not have headers anymore, got: %s", opt.Label)
}
}
}
@@ -59,9 +59,9 @@ func TestGetTranscriptionModelOptions_OpenAI_ShowsCapabilities(t *testing.T) {
// gpt-4o-transcribe and gpt-4o-mini-transcribe should have [batch+streaming]
for _, opt := range options {
if strings.Contains(opt.Value, "gpt-4o") {
if !strings.Contains(opt.Key, "[batch+streaming]") {
t.Errorf("gpt-4o model %s should have [batch+streaming] tag: %s", opt.Value, opt.Key)
if strings.Contains(opt.ID, "gpt-4o") {
if !strings.Contains(opt.Label, "[batch+streaming]") {
t.Errorf("gpt-4o model %s should have [batch+streaming] tag: %s", opt.ID, opt.Label)
}
}
}
@@ -76,8 +76,8 @@ func TestGetTranscriptionModelOptions_Deepgram_ShowsBothModes(t *testing.T) {
}
for _, opt := range options {
if !strings.Contains(opt.Key, "[batch+streaming]") {
t.Errorf("deepgram model %s should have [batch+streaming] tag: %s", opt.Value, opt.Key)
if !strings.Contains(opt.Label, "[batch+streaming]") {
t.Errorf("deepgram model %s should have [batch+streaming] tag: %s", opt.ID, opt.Label)
}
}
}
@@ -93,8 +93,8 @@ func TestGetTranscriptionModelOptions_Groq_BatchOnly(t *testing.T) {
// batch-only models should not have any mode tags
for _, opt := range options {
if strings.Contains(opt.Key, "[streaming]") || strings.Contains(opt.Key, "[batch]") {
t.Errorf("batch-only model should not have mode tags: %s", opt.Key)
if strings.Contains(opt.Label, "[streaming]") || strings.Contains(opt.Label, "[batch]") {
t.Errorf("batch-only model should not have mode tags: %s", opt.Label)
}
}
}
-161
View File
@@ -1,161 +0,0 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/config"
)
// 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) {
fmt.Println(Logo())
fmt.Println()
fmt.Println(StyleMuted.Render("Voice-powered typing for Wayland/Hyprland"))
fmt.Println()
// 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 {
return &ConfigureResult{Cancelled: true}, nil
}
// 3. LLM - same screen as menu
configuredProviders, err = editLLM(cfg, configuredProviders)
if err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
// 4. Keywords
keywords, err := inputKeywords(cfg.Keywords)
if err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
cfg.Keywords = keywords
// 6. Injection backends
backends, err := selectBackends(cfg.Injection.Backends)
if err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
cfg.Injection.Backends = backends
// 7. Notifications - same screen as menu
if err := editNotifications(cfg); err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
// 8. Advanced settings prompt
wantAdvanced, err := askAdvancedSettings()
if err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
if wantAdvanced {
if err := editAdvanced(cfg, true); err != nil {
return &ConfigureResult{Cancelled: true}, nil
}
}
return &ConfigureResult{Config: cfg, Cancelled: false}, 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 askAdvancedSettings() (bool, error) {
var want bool
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Configure advanced settings?").
Description("Recording parameters, injection timeouts, etc.").
Value(&want),
),
).WithTheme(getTheme())
if err := form.Run(); err != nil {
return false, err
}
return want, nil
}
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
package tui
import (
"fmt"
"sort"
"github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/models/whisper"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// AllProviders is the list of all supported cloud providers (require API keys).
var AllProviders = []string{"openai", "groq", "mistral", "elevenlabs", "deepgram"}
// LocalProviders is the list of local providers (no API key required).
var LocalProviders = []string{"whisper-cpp"}
// providerDisplayNames maps provider IDs to human-readable names.
var providerDisplayNames = map[string]string{
"openai": "OpenAI",
"groq": "Groq",
"mistral": "Mistral",
"elevenlabs": "ElevenLabs",
"deepgram": "Deepgram",
"whisper-cpp": "Whisper.cpp (local)",
}
func getProviderDisplayName(providerName string) string {
if name, ok := providerDisplayNames[providerName]; ok {
return name
}
return providerName
}
func maskAPIKey(key string) string {
if len(key) <= 8 {
return "***"
}
return key[:7] + "..." + key[len(key)-4:]
}
func hasUserChanges(cfg *config.Config) bool {
if len(cfg.Providers) > 0 {
return true
}
if cfg.Transcription.APIKey != "" {
return true
}
return false
}
func getConfiguredProviders(cfg *config.Config) []string {
providers := make([]string, 0, len(cfg.Providers))
for name, pc := range cfg.Providers {
if pc.APIKey != "" {
providers = append(providers, name)
}
}
sort.Strings(providers)
return providers
}
func isProviderConfigured(cfg *config.Config, providerName string) bool {
if pc, ok := cfg.Providers[providerName]; ok {
return pc.APIKey != ""
}
return false
}
func mapConfigProviderToRegistry(configProvider string) string {
switch configProvider {
case "groq-transcription", "groq-translation":
return "groq"
case "mistral-transcription":
return "mistral"
default:
return configProvider
}
}
func buildModelLabel(m provider.Model) string {
label := fmt.Sprintf("%s (%s)", m.Name, m.Description)
if m.Local && m.LocalInfo != nil {
label += fmt.Sprintf(" [%s]", m.LocalInfo.Size)
}
if m.SupportsBothModes() {
label += " [batch+streaming]"
} else if m.SupportsStreaming {
label += " [streaming]"
}
return label
}
func getTranscriptionModelOptions(configProvider string) []modelOption {
if configProvider == "groq-translation" {
return []modelOption{{ID: "whisper-large-v3", Label: "whisper-large-v3 (only option)"}}
}
registryName := mapConfigProviderToRegistry(configProvider)
p := provider.GetProvider(registryName)
if p == nil {
return []modelOption{}
}
models := provider.ModelsOfType(p, provider.Transcription)
options := make([]modelOption, 0, len(models))
for _, m := range models {
label := buildModelLabel(m)
if m.Local && registryName == "whisper-cpp" {
if whisper.IsInstalled(m.ID) {
label = "[x] " + label
} else {
label = "[ ] " + label
}
}
options = append(options, modelOption{ID: m.ID, Label: label})
}
return options
}
-32
View File
@@ -1,32 +0,0 @@
package tui
import (
"github.com/charmbracelet/huh"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// getModelLanguageOptions returns language options supported by the given model
func getModelLanguageOptions(model *provider.Model, currentLang string) []huh.Option[string] {
var options []huh.Option[string]
// auto-detect is always first
autoLabel := "Auto-detect (recommended)"
if currentLang == "" {
autoLabel += " (current)"
}
options = append(options, huh.NewOption(autoLabel, ""))
if model == nil {
return options
}
for _, code := range model.SupportedLanguages {
label := code
if code == currentLang {
label += " (current)"
}
options = append(options, huh.NewOption(label, code))
}
return options
}
+632
View File
@@ -0,0 +1,632 @@
package tui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type listScreen struct {
state *wizardState
title string
desc []string
list list.Model
footer string
errText string
onPick func(optionItem) screen
onBack func() screen
}
func newListScreen(state *wizardState, title string, desc []string, items []optionItem, onPick func(optionItem) screen, onBack func() screen) *listScreen {
delegate := list.NewDefaultDelegate()
l := list.New(itemsToList(items), delegate, 0, 0)
l.DisableQuitKeybindings()
l.SetShowHelp(false)
l.SetFilteringEnabled(true)
l.SetShowStatusBar(false)
l.Title = title
return &listScreen{
state: state,
title: title,
desc: desc,
list: l,
footer: "enter select • esc back • / filter",
onPick: onPick,
onBack: onBack,
}
}
func (s *listScreen) Init() tea.Cmd {
return nil
}
func (s *listScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
s.list.SetSize(msg.Width-4, msg.Height-8)
case tea.KeyMsg:
switch msg.String() {
case "enter":
if s.list.FilterState() != list.Filtering {
if item, ok := s.list.SelectedItem().(optionItem); ok {
if item.disabled {
s.errText = "That option isn't available in this environment."
break
}
if s.onPick != nil {
return s.onPick(item), nil
}
}
}
case "esc", "q":
if s.list.FilterState() == list.Unfiltered {
if s.onBack != nil {
return s.onBack(), nil
}
}
}
}
var cmd tea.Cmd
s.list, cmd = s.list.Update(msg)
return s, cmd
}
func (s *listScreen) View() string {
header := renderHeader(s.title, s.desc, s.errText)
footer := renderFooter(s.footer, s.list.FilterState() != list.Unfiltered)
return header + s.list.View() + "\n" + footer
}
type confirmScreen struct {
state *wizardState
title string
desc []string
list list.Model
footer string
onYes func() screen
onNo func() screen
errText string
}
func newConfirmScreen(state *wizardState, title string, desc []string, yesLabel, noLabel string, onYes func() screen, onNo func() screen) *confirmScreen {
items := []optionItem{
{title: yesLabel, value: "yes"},
{title: noLabel, value: "no"},
}
delegate := list.NewDefaultDelegate()
l := list.New(itemsToList(items), delegate, 0, 0)
l.DisableQuitKeybindings()
l.SetShowHelp(false)
l.SetFilteringEnabled(false)
l.SetShowStatusBar(false)
l.Title = title
return &confirmScreen{state: state, title: title, desc: desc, list: l, footer: "enter select • esc back", onYes: onYes, onNo: onNo}
}
func (s *confirmScreen) Init() tea.Cmd { return nil }
func (s *confirmScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
s.list.SetSize(msg.Width-4, msg.Height-8)
case tea.KeyMsg:
switch msg.String() {
case "enter":
if item, ok := s.list.SelectedItem().(optionItem); ok {
if item.value == "yes" && s.onYes != nil {
return s.onYes(), nil
}
if item.value == "no" && s.onNo != nil {
return s.onNo(), nil
}
}
case "esc", "q":
if s.onNo != nil {
return s.onNo(), nil
}
}
}
var cmd tea.Cmd
s.list, cmd = s.list.Update(msg)
return s, cmd
}
func (s *confirmScreen) View() string {
header := renderHeader(s.title, s.desc, s.errText)
footer := renderFooter(s.footer, false)
return header + s.list.View() + "\n" + footer
}
type inputScreen struct {
state *wizardState
title string
desc []string
input textinput.Model
footer string
errText string
onSubmit func(string) screen
onCancel func() screen
validateFn func(string) error
}
func newInputScreen(state *wizardState, title string, desc []string, value string, placeholder string, password bool, validateFn func(string) error, onSubmit func(string) screen, onCancel func() screen) *inputScreen {
input := textinput.New()
input.SetValue(value)
input.Placeholder = placeholder
if password {
input.EchoMode = textinput.EchoPassword
input.EchoCharacter = '*'
}
input.Focus()
input.CharLimit = 0
return &inputScreen{
state: state,
title: title,
desc: desc,
input: input,
footer: "enter save • esc back",
onSubmit: onSubmit,
onCancel: onCancel,
validateFn: validateFn,
}
}
func (s *inputScreen) Init() tea.Cmd { return textinput.Blink }
func (s *inputScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
value := strings.TrimSpace(s.input.Value())
if s.validateFn != nil {
if err := s.validateFn(value); err != nil {
s.errText = err.Error()
break
}
}
if s.onSubmit != nil {
return s.onSubmit(value), nil
}
case "esc", "q":
if s.onCancel != nil {
return s.onCancel(), nil
}
}
}
var cmd tea.Cmd
s.input, cmd = s.input.Update(msg)
return s, cmd
}
func (s *inputScreen) View() string {
header := renderHeader(s.title, s.desc, s.errText)
footer := renderFooter(s.footer, false)
return header + s.input.View() + "\n\n" + footer
}
type multiSelectScreen struct {
state *wizardState
title string
desc []string
list list.Model
footer string
errText string
onSubmit func([]toggleItem) screen
onCancel func() screen
requireOne bool
}
func newMultiSelectScreen(state *wizardState, title string, desc []string, items []toggleItem, requireOne bool, onSubmit func([]toggleItem) screen, onCancel func() screen) *multiSelectScreen {
delegate := list.NewDefaultDelegate()
l := list.New(toggleItemsToList(items), delegate, 0, 0)
l.DisableQuitKeybindings()
l.SetShowHelp(false)
l.SetFilteringEnabled(true)
l.SetShowStatusBar(false)
l.Title = title
return &multiSelectScreen{
state: state,
title: title,
desc: desc,
list: l,
footer: "space toggle • enter save • esc back • / filter",
onSubmit: onSubmit,
onCancel: onCancel,
requireOne: requireOne,
}
}
func (s *multiSelectScreen) Init() tea.Cmd { return nil }
func (s *multiSelectScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
s.list.SetSize(msg.Width-4, msg.Height-8)
case tea.KeyMsg:
switch msg.String() {
case " ":
idx := s.list.Index()
items := s.list.Items()
if idx >= 0 && idx < len(items) {
if item, ok := items[idx].(toggleItem); ok {
item.selected = !item.selected
items[idx] = item
s.list.SetItems(items)
}
}
case "enter":
items := listToToggleItems(s.list.Items())
if s.requireOne {
has := false
for _, item := range items {
if item.selected {
has = true
break
}
}
if !has {
s.errText = "Select at least one option to continue."
break
}
}
if s.onSubmit != nil {
return s.onSubmit(items), nil
}
case "esc", "q":
if s.list.FilterState() == list.Unfiltered {
if s.onCancel != nil {
return s.onCancel(), nil
}
}
}
}
var cmd tea.Cmd
s.list, cmd = s.list.Update(msg)
return s, cmd
}
func (s *multiSelectScreen) View() string {
header := renderHeader(s.title, s.desc, s.errText)
footer := renderFooter(s.footer, s.list.FilterState() != list.Unfiltered)
return header + s.list.View() + "\n" + footer
}
type infoScreen struct {
state *wizardState
title string
desc []string
footer string
next func() screen
back func() screen
}
func newInfoScreen(state *wizardState, title string, desc []string, next func() screen, back func() screen) *infoScreen {
return &infoScreen{state: state, title: title, desc: desc, footer: "enter continue • esc back", next: next, back: back}
}
func (s *infoScreen) Init() tea.Cmd { return nil }
func (s *infoScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
if s.next != nil {
return s.next(), nil
}
case "esc", "q":
if s.back != nil {
return s.back(), nil
}
}
}
return s, nil
}
func (s *infoScreen) View() string {
header := renderHeader(s.title, s.desc, "")
footer := renderFooter(s.footer, false)
return header + "\n" + footer
}
type formField struct {
key string
label string
desc string
input textinput.Model
validate func(string) error
required bool
sensitive bool
}
type formScreen struct {
state *wizardState
title string
desc []string
fields []formField
focused int
footer string
errText string
onSubmit func(map[string]string) screen
onCancel func() screen
}
func newFormScreen(state *wizardState, title string, desc []string, fields []formField, onSubmit func(map[string]string) screen, onCancel func() screen) *formScreen {
if len(fields) > 0 {
fields[0].input.Focus()
}
return &formScreen{state: state, title: title, desc: desc, fields: fields, onSubmit: onSubmit, onCancel: onCancel}
}
func (s *formScreen) Init() tea.Cmd { return textinput.Blink }
func (s *formScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc", "q":
if s.onCancel != nil {
return s.onCancel(), nil
}
case "tab", "down":
s.moveFocus(1)
case "shift+tab", "up":
s.moveFocus(-1)
case "enter":
if s.focused == len(s.fields)-1 {
values, err := s.validateAll()
if err != nil {
s.errText = err.Error()
break
}
if s.onSubmit != nil {
return s.onSubmit(values), nil
}
} else {
s.moveFocus(1)
}
}
}
var cmd tea.Cmd
if s.focused >= 0 && s.focused < len(s.fields) {
s.fields[s.focused].input, cmd = s.fields[s.focused].input.Update(msg)
}
return s, cmd
}
func (s *formScreen) View() string {
header := renderHeader(s.title, s.desc, s.errText)
body := strings.Builder{}
for i, field := range s.fields {
label := StyleLabel.Render(field.label)
if i == s.focused {
label = StyleHighlight.Render(field.label)
}
body.WriteString(label)
if field.desc != "" {
body.WriteString("\n")
body.WriteString(StyleSubtle.Render(field.desc))
}
body.WriteString("\n")
body.WriteString(field.input.View())
body.WriteString("\n\n")
}
footer := renderFooter(s.footer, false)
return header + body.String() + footer
}
func (s *formScreen) moveFocus(delta int) {
if len(s.fields) == 0 {
return
}
s.fields[s.focused].input.Blur()
s.focused = (s.focused + delta + len(s.fields)) % len(s.fields)
s.fields[s.focused].input.Focus()
}
func (s *formScreen) validateAll() (map[string]string, error) {
values := make(map[string]string, len(s.fields))
for _, field := range s.fields {
value := strings.TrimSpace(field.input.Value())
if field.required && value == "" {
return nil, fmt.Errorf("%s is required", field.label)
}
if field.validate != nil {
if err := field.validate(value); err != nil {
return nil, err
}
}
values[field.key] = value
}
return values, nil
}
type downloadProgressMsg struct {
downloaded int64
total int64
}
type downloadDoneMsg struct {
err error
}
type downloadScreen struct {
state *wizardState
title string
desc []string
modelID string
progress int
total int64
footer string
errText string
onSuccess func() screen
onCancel func() screen
updates chan tea.Msg
started bool
}
func newDownloadScreen(state *wizardState, title string, desc []string, modelID string, onSuccess func() screen, onCancel func() screen) *downloadScreen {
return &downloadScreen{
state: state,
title: title,
desc: desc,
modelID: modelID,
onSuccess: onSuccess,
onCancel: onCancel,
updates: make(chan tea.Msg),
}
}
func (s *downloadScreen) Init() tea.Cmd {
if s.started {
return listenForDownload(s.updates)
}
s.started = true
return tea.Batch(s.startDownloadCmd(), listenForDownload(s.updates))
}
func (s *downloadScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case downloadProgressMsg:
s.total = msg.total
if msg.total > 0 {
s.progress = int(msg.downloaded * 100 / msg.total)
}
return s, listenForDownload(s.updates)
case downloadDoneMsg:
if msg.err != nil {
s.errText = msg.err.Error()
return s, nil
}
if s.onSuccess != nil {
return s.onSuccess(), nil
}
case tea.KeyMsg:
switch msg.String() {
case "esc", "q":
if s.onCancel != nil {
return s.onCancel(), nil
}
}
}
return s, nil
}
func (s *downloadScreen) View() string {
header := renderHeader(s.title, s.desc, s.errText)
progressLine := "Downloading"
if s.total > 0 {
progressLine = fmt.Sprintf("Downloading... %d%%", s.progress)
}
body := StyleMuted.Render(progressLine) + "\n\n"
footer := renderFooter(s.footer, false)
return header + body + footer
}
func (s *downloadScreen) startDownloadCmd() tea.Cmd {
modelID := s.modelID
ch := s.updates
return func() tea.Msg {
err := downloadWhisperModel(modelID, func(downloaded, total int64) {
ch <- downloadProgressMsg{downloaded: downloaded, total: total}
})
ch <- downloadDoneMsg{err: err}
return nil
}
}
func listenForDownload(ch <-chan tea.Msg) tea.Cmd {
return func() tea.Msg {
msg, ok := <-ch
if !ok {
return nil
}
return msg
}
}
func itemsToList(items []optionItem) []list.Item {
result := make([]list.Item, len(items))
for i, item := range items {
result[i] = item
}
return result
}
func toggleItemsToList(items []toggleItem) []list.Item {
result := make([]list.Item, len(items))
for i, item := range items {
result[i] = item
}
return result
}
func listToToggleItems(items []list.Item) []toggleItem {
result := make([]toggleItem, 0, len(items))
for _, item := range items {
if t, ok := item.(toggleItem); ok {
result = append(result, t)
}
}
return result
}
func renderHeader(title string, desc []string, errText string) string {
var b strings.Builder
if title != "" {
b.WriteString(StyleHeader.Render(title))
b.WriteString("\n")
}
for _, line := range desc {
if line == "" {
continue
}
b.WriteString(StyleMuted.Render(line))
b.WriteString("\n")
}
if errText != "" {
b.WriteString("\n")
b.WriteString(StyleError.Render(errText))
b.WriteString("\n")
}
b.WriteString("\n")
return b.String()
}
func renderFooter(extra string, filtering bool) string {
if extra != "" {
return StyleSubtle.Render(extra)
}
if filtering {
return StyleSubtle.Render("enter apply • esc clear")
}
return StyleSubtle.Render("enter select • esc back")
}
func makeInputField(key, label, desc, value, placeholder string, validate func(string) error) formField {
input := textinput.New()
input.SetValue(value)
input.Placeholder = placeholder
input.Prompt = ""
input.Cursor.Style = lipgloss.NewStyle().Foreground(ColorPrimary)
return formField{key: key, label: label, desc: desc, input: input, validate: validate}
}
func parseDurationOrEmpty(value string) (time.Duration, error) {
if strings.TrimSpace(value) == "" {
return 0, fmt.Errorf("duration is required")
}
return time.ParseDuration(value)
}
+66
View File
@@ -0,0 +1,66 @@
package tui
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/leonardotrapani/hyprvoice/internal/config"
)
// ConfigureResult holds the configuration result from the TUI.
type ConfigureResult struct {
Config *config.Config
Cancelled bool
}
type screen interface {
Init() tea.Cmd
Update(tea.Msg) (screen, tea.Cmd)
View() string
}
type wizardState struct {
cfg *config.Config
onboarding bool
cancelled bool
err error
result *ConfigureResult
}
type optionItem struct {
title string
desc string
value string
disabled bool
}
func (i optionItem) Title() string { return i.title }
func (i optionItem) Description() string { return i.desc }
func (i optionItem) FilterValue() string {
return strings.TrimSpace(i.title + " " + i.desc)
}
type toggleItem struct {
title string
desc string
value string
selected bool
}
func (i toggleItem) Title() string {
prefix := "[ ]"
if i.selected {
prefix = "[x]"
}
return prefix + " " + i.title
}
func (i toggleItem) Description() string { return i.desc }
func (i toggleItem) FilterValue() string {
return strings.TrimSpace(i.title + " " + i.desc)
}
type modelOption struct {
ID string
Label string
}
+111
View File
@@ -0,0 +1,111 @@
package tui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/leonardotrapani/hyprvoice/internal/config"
)
type wizardModel struct {
state *wizardState
screen screen
width int
height int
}
func newWizardModel(state *wizardState, start screen) wizardModel {
return wizardModel{state: state, screen: start}
}
func (m wizardModel) Init() tea.Cmd {
if m.screen == nil {
return tea.Quit
}
return m.screen.Init()
}
func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "ctrl+c" {
m.state.cancelled = true
m.state.result = &ConfigureResult{Cancelled: true}
return m, tea.Quit
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
if m.screen == nil {
return m, tea.Quit
}
next, cmd := m.screen.Update(msg)
if next == nil {
if m.state.result == nil {
m.state.result = &ConfigureResult{Config: m.state.cfg, Cancelled: m.state.cancelled}
}
return m, tea.Quit
}
if next != m.screen {
var sizeCmd tea.Cmd
if m.width > 0 && m.height > 0 {
updated, scmd := next.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
if updated == nil {
if m.state.result == nil {
m.state.result = &ConfigureResult{Config: m.state.cfg, Cancelled: m.state.cancelled}
}
return m, tea.Quit
}
next = updated
sizeCmd = scmd
}
m.screen = next
initCmd := m.screen.Init()
return m, tea.Batch(cmd, sizeCmd, initCmd)
}
m.screen = next
return m, cmd
}
func (m wizardModel) View() string {
if m.screen == nil {
return ""
}
return m.screen.View()
}
// Run starts the TUI configuration wizard.
// If onboarding is true, forces the guided wizard flow even if config exists.
func Run(existingConfig *config.Config, onboarding bool) (*ConfigureResult, error) {
if existingConfig == nil {
return nil, fmt.Errorf("config is required")
}
state := &wizardState{cfg: existingConfig}
if onboarding || !hasUserChanges(existingConfig) {
state.onboarding = true
}
var start screen
if state.onboarding {
start = newWelcomeScreen(state)
} else {
start = newMenuScreen(state)
}
model := newWizardModel(state, start)
if _, err := tea.NewProgram(model, tea.WithAltScreen()).Run(); err != nil {
return &ConfigureResult{Cancelled: true}, err
}
if state.err != nil {
return &ConfigureResult{Cancelled: true}, state.err
}
if state.result == nil {
state.result = &ConfigureResult{Config: existingConfig, Cancelled: state.cancelled}
}
return state.result, nil
}
+28
View File
@@ -0,0 +1,28 @@
package tui
import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/leonardotrapani/hyprvoice/internal/config"
)
func TestWizardMenuTransitionAppliesSize(t *testing.T) {
cfg := &config.Config{}
state := &wizardState{cfg: cfg}
model := newWizardModel(state, newMenuScreen(state))
updated, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
model = updated.(wizardModel)
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(wizardModel)
listScreen, ok := model.screen.(*listScreen)
if !ok {
t.Fatalf("expected list screen after selection, got %T", model.screen)
}
if listScreen.list.Width() <= 0 || listScreen.list.Height() <= 0 {
t.Fatalf("expected list size to be set, got width=%d height=%d", listScreen.list.Width(), listScreen.list.Height())
}
}