From 0b0e0841556e0b67ccbc0df3f7b08d570cea0a3d Mon Sep 17 00:00:00 2001 From: leonardotrapani Date: Sun, 1 Feb 2026 20:40:17 +0100 Subject: [PATCH] feat: use new ui bubbletea --- go.mod | 13 +- go.sum | 26 +- internal/config/validate.go | 9 +- internal/provider/language_label.go | 30 + internal/provider/provider.go | 9 +- internal/provider/provider_test.go | 13 +- internal/transcriber/adapter_deepgram.go | 5 +- .../transcriber/adapter_deepgram_batch.go | 5 +- internal/transcriber/deepgram_language.go | 15 + internal/tui/configure.go | 204 --- internal/tui/configure_advanced.go | 229 ---- internal/tui/configure_helpers.go | 110 -- internal/tui/configure_llm.go | 295 ---- internal/tui/configure_notifications.go | 251 ---- internal/tui/configure_providers.go | 243 ---- internal/tui/configure_transcription.go | 351 ----- internal/tui/configure_transcription_test.go | 28 +- internal/tui/configure_wizard.go | 161 --- internal/tui/flows.go | 1194 +++++++++++++++++ internal/tui/helpers.go | 123 ++ internal/tui/languages.go | 32 - internal/tui/screens.go | 632 +++++++++ internal/tui/types.go | 66 + internal/tui/wizard.go | 111 ++ internal/tui/wizard_test.go | 28 + 25 files changed, 2249 insertions(+), 1934 deletions(-) create mode 100644 internal/provider/language_label.go create mode 100644 internal/transcriber/deepgram_language.go delete mode 100644 internal/tui/configure.go delete mode 100644 internal/tui/configure_advanced.go delete mode 100644 internal/tui/configure_helpers.go delete mode 100644 internal/tui/configure_llm.go delete mode 100644 internal/tui/configure_notifications.go delete mode 100644 internal/tui/configure_providers.go delete mode 100644 internal/tui/configure_transcription.go delete mode 100644 internal/tui/configure_wizard.go create mode 100644 internal/tui/flows.go create mode 100644 internal/tui/helpers.go delete mode 100644 internal/tui/languages.go create mode 100644 internal/tui/screens.go create mode 100644 internal/tui/types.go create mode 100644 internal/tui/wizard.go create mode 100644 internal/tui/wizard_test.go diff --git a/go.mod b/go.mod index 8b8e118..f1c12e5 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,11 @@ go 1.24.5 require ( github.com/BurntSushi/toml v1.5.0 - github.com/charmbracelet/huh v0.8.0 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 + github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/fsnotify/fsnotify v1.9.0 github.com/gorilla/websocket v1.5.3 - github.com/muesli/termenv v0.16.0 github.com/sashabaranov/go-openai v1.41.1 github.com/spf13/cobra v1.9.1 golang.org/x/text v0.23.0 @@ -17,26 +17,23 @@ require ( require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/sys v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index 811fd78..e6e3e71 100644 --- a/go.sum +++ b/go.sum @@ -1,48 +1,28 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= -github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= -github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= -github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= -github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= -github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= -github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= -github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= -github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= -github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= -github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -51,6 +31,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -59,8 +41,6 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= -github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -71,6 +51,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sashabaranov/go-openai v1.41.1 h1:zf5tM+GuxpyiyD9XZg8nCqu52eYFQg9OOew0gnIuDy4= github.com/sashabaranov/go-openai v1.41.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= diff --git a/internal/config/validate.go b/internal/config/validate.go index eb258f7..8bc58f4 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -207,10 +207,15 @@ func ValidateModelLanguageCompatibility(registryProvider, modelID, langCode stri docsHint = fmt.Sprintf(" See %s for full list.", model.DocsURL) } + langLabel := provider.LanguageLabel(langCode) + if langLabel == "" { + langLabel = fmt.Sprintf("language '%s'", langCode) + } + return fmt.Errorf( - "model %s does not support language '%s'.%s Supported: %s%s", + "model %s does not support %s.%s Supported: %s%s", model.Name, - langCode, + langLabel, docsHint, strings.Join(supported, ", "), suffix, diff --git a/internal/provider/language_label.go b/internal/provider/language_label.go new file mode 100644 index 0000000..b84aa42 --- /dev/null +++ b/internal/provider/language_label.go @@ -0,0 +1,30 @@ +package provider + +import ( + "fmt" + "strings" + + "golang.org/x/text/language" + "golang.org/x/text/language/display" +) + +// LanguageLabel returns a human-readable label for a language code. +// Example: "es" -> "Spanish (es)", "en-US" -> "English (United States) (en-US)". +func LanguageLabel(code string) string { + if code == "" { + return "" + } + + normalized := strings.ReplaceAll(code, "_", "-") + tag, err := language.Parse(normalized) + if err != nil { + return fmt.Sprintf("language '%s'", code) + } + + name := display.English.Tags().Name(tag) + if name == "" || strings.EqualFold(name, code) { + return fmt.Sprintf("language '%s'", code) + } + + return fmt.Sprintf("%s (%s)", name, code) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index a7f1d81..d555f63 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -163,10 +163,15 @@ func ValidateModelLanguage(providerName, modelID, langCode string) error { docsHint = fmt.Sprintf(" See %s for full list.", model.DocsURL) } + langLabel := LanguageLabel(langCode) + if langLabel == "" { + langLabel = fmt.Sprintf("language '%s'", langCode) + } + return fmt.Errorf( - "model %s does not support language '%s'.%s Supported: %s%s", + "model %s does not support %s.%s Supported: %s%s", model.Name, - langCode, + langLabel, docsHint, strings.Join(supported, ", "), suffix, diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index 0c75682..be5fd8f 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -283,9 +283,9 @@ func TestValidateModelLanguage_ErrorFormat(t *testing.T) { t.Errorf("error should contain docs URL, got: %s", errMsg) } - // should contain language code - if !strings.Contains(errMsg, "'es'") { - t.Errorf("error should contain language code, got: %s", errMsg) + // should contain language label + if !strings.Contains(errMsg, "Spanish (es)") { + t.Errorf("error should contain language label, got: %s", errMsg) } // should contain supported languages (English-only has just 'en') @@ -387,10 +387,11 @@ func TestElevenLabsProvider(t *testing.T) { t.Errorf("scribe_v2_realtime AdapterType=%q, want 'elevenlabs-streaming'", scribeV2Realtime.AdapterType) } - // All models have explicit SupportedLanguages from docs (subset of our 57) + // All models should share the same supported language list + wantLangCount := len(elevenLabsTranscriptionLanguages) for _, m := range models { - if len(m.SupportedLanguages) != 57 { - t.Errorf("model %q has %d languages, want 57", m.ID, len(m.SupportedLanguages)) + if len(m.SupportedLanguages) != wantLangCount { + t.Errorf("model %q has %d languages, want %d", m.ID, len(m.SupportedLanguages), wantLangCount) } } } diff --git a/internal/transcriber/adapter_deepgram.go b/internal/transcriber/adapter_deepgram.go index 4e70784..a17d1e9 100644 --- a/internal/transcriber/adapter_deepgram.go +++ b/internal/transcriber/adapter_deepgram.go @@ -230,8 +230,9 @@ func (a *DeepgramAdapter) buildURL() (string, error) { q.Set("punctuate", "true") // add language if specified - if a.language != "" { - q.Set("language", a.language) + lang := normalizeDeepgramLanguage(a.language) + if lang != "" { + q.Set("language", lang) } if len(a.keywords) > 0 { diff --git a/internal/transcriber/adapter_deepgram_batch.go b/internal/transcriber/adapter_deepgram_batch.go index 03c45c0..e8d497d 100644 --- a/internal/transcriber/adapter_deepgram_batch.go +++ b/internal/transcriber/adapter_deepgram_batch.go @@ -118,8 +118,9 @@ func (a *DeepgramBatchAdapter) buildURL() (string, error) { q.Set("punctuate", "true") // add language if specified - if a.language != "" { - q.Set("language", a.language) + lang := normalizeDeepgramLanguage(a.language) + if lang != "" { + q.Set("language", lang) } if len(a.keywords) > 0 { diff --git a/internal/transcriber/deepgram_language.go b/internal/transcriber/deepgram_language.go new file mode 100644 index 0000000..b150acf --- /dev/null +++ b/internal/transcriber/deepgram_language.go @@ -0,0 +1,15 @@ +package transcriber + +import "strings" + +func normalizeDeepgramLanguage(code string) string { + if code == "" { + return "" + } + + if strings.EqualFold(code, "en") || strings.EqualFold(code, "en-us") || strings.EqualFold(code, "en_us") { + return "en-US" + } + + return code +} diff --git a/internal/tui/configure.go b/internal/tui/configure.go deleted file mode 100644 index abbb948..0000000 --- a/internal/tui/configure.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/configure_advanced.go b/internal/tui/configure_advanced.go deleted file mode 100644 index 5abcf08..0000000 --- a/internal/tui/configure_advanced.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/configure_helpers.go b/internal/tui/configure_helpers.go deleted file mode 100644 index bd9a731..0000000 --- a/internal/tui/configure_helpers.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/configure_llm.go b/internal/tui/configure_llm.go deleted file mode 100644 index f2526ca..0000000 --- a/internal/tui/configure_llm.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/configure_notifications.go b/internal/tui/configure_notifications.go deleted file mode 100644 index 80d489c..0000000 --- a/internal/tui/configure_notifications.go +++ /dev/null @@ -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(¬ifType), - ), - ).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 -} diff --git a/internal/tui/configure_providers.go b/internal/tui/configure_providers.go deleted file mode 100644 index 9ab8a47..0000000 --- a/internal/tui/configure_providers.go +++ /dev/null @@ -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) -} diff --git a/internal/tui/configure_transcription.go b/internal/tui/configure_transcription.go deleted file mode 100644 index 3353e79..0000000 --- a/internal/tui/configure_transcription.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/configure_transcription_test.go b/internal/tui/configure_transcription_test.go index c1e814e..01f42f5 100644 --- a/internal/tui/configure_transcription_test.go +++ b/internal/tui/configure_transcription_test.go @@ -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) } } } diff --git a/internal/tui/configure_wizard.go b/internal/tui/configure_wizard.go deleted file mode 100644 index acac48a..0000000 --- a/internal/tui/configure_wizard.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/flows.go b/internal/tui/flows.go new file mode 100644 index 0000000..e328603 --- /dev/null +++ b/internal/tui/flows.go @@ -0,0 +1,1194 @@ +package tui + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/leonardotrapani/hyprvoice/internal/config" + "github.com/leonardotrapani/hyprvoice/internal/deps" + "github.com/leonardotrapani/hyprvoice/internal/models/whisper" + "github.com/leonardotrapani/hyprvoice/internal/notify" + "github.com/leonardotrapani/hyprvoice/internal/provider" +) + +const ( + menuProviders = "providers" + menuVoiceModel = "voice_model" + menuLLM = "llm" + menuKeywords = "keywords" + menuInjection = "injection" + menuNotifications = "notifications" + menuAdvanced = "advanced" + menuSave = "save" + menuDiscard = "discard" +) + +func newWelcomeScreen(state *wizardState) screen { + desc := []string{ + "Voice-powered typing for Wayland/Hyprland.", + "Let's set up your configuration.", + } + s := newInfoScreen(state, "Hyprvoice Configure", desc, func() screen { + return onboardingProvidersScreen(state) + }, func() screen { + state.cancelled = true + state.result = &ConfigureResult{Cancelled: true} + return nil + }) + s.footer = "enter start • esc quit" + return s +} + +func onboardingProvidersScreen(state *wizardState) screen { + return newProvidersScreen(state, + func() screen { return newWelcomeScreen(state) }, + func() screen { return onboardingVoiceProviderScreen(state) }, + true, + ) +} + +func onboardingVoiceProviderScreen(state *wizardState) screen { + return newVoiceProviderScreen(state, + func() screen { return onboardingProvidersScreen(state) }, + func() screen { return onboardingLLMScreen(state) }, + ) +} + +func onboardingLLMScreen(state *wizardState) screen { + return newLLMEnableScreen(state, + func() screen { return onboardingVoiceProviderScreen(state) }, + func() screen { return newKeywordsScreen(state, func() screen { return onboardingLLMScreen(state) }) }, + ) +} + +func newMenuScreen(state *wizardState) screen { + items := []optionItem{ + {title: formatProvidersLabel(state.cfg), value: menuProviders}, + {title: formatVoiceModelLabel(state.cfg), value: menuVoiceModel}, + {title: formatLLMLabel(state.cfg), value: menuLLM}, + {title: formatKeywordsLabel(state.cfg), value: menuKeywords}, + {title: formatInjectionLabel(state.cfg), value: menuInjection}, + {title: formatNotificationsLabel(state.cfg), value: menuNotifications}, + {title: "Advanced Settings", value: menuAdvanced}, + {title: "Save & Exit", value: menuSave}, + {title: "Discard & Exit", value: menuDiscard}, + } + + desc := []string{"Select a section to update."} + screen := newListScreen(state, "Configuration Menu", desc, items, func(item optionItem) screen { + switch item.value { + case menuProviders: + return newProvidersScreen(state, func() screen { return newMenuScreen(state) }, func() screen { return newMenuScreen(state) }, false) + case menuVoiceModel: + return newVoiceProviderScreen(state, func() screen { return newMenuScreen(state) }, func() screen { return newMenuScreen(state) }) + case menuLLM: + return newLLMEnableScreen(state, func() screen { return newMenuScreen(state) }, func() screen { return newMenuScreen(state) }) + case menuKeywords: + return newKeywordsScreen(state, func() screen { return newMenuScreen(state) }) + case menuInjection: + return newInjectionScreen(state, func() screen { return newMenuScreen(state) }) + case menuNotifications: + return newNotificationsScreen(state, func() screen { return newMenuScreen(state) }) + case menuAdvanced: + return newAdvancedMenuScreen(state, func() screen { return newMenuScreen(state) }, false) + case menuSave: + return newSummaryScreen(state, func() screen { return newMenuScreen(state) }) + case menuDiscard: + state.cancelled = true + state.result = &ConfigureResult{Cancelled: true} + return nil + default: + return newMenuScreen(state) + } + }, func() screen { + state.cancelled = true + state.result = &ConfigureResult{Cancelled: true} + return nil + }) + screen.footer = "enter select • esc cancel • / filter" + return screen +} + +func newProvidersScreen(state *wizardState, onBack func() screen, onNext func() screen, onboarding bool) screen { + items := make([]optionItem, 0, len(AllProviders)+1) + for _, name := range AllProviders { + items = append(items, optionItem{title: formatProviderOption(state.cfg, name), value: name}) + } + + exitLabel := "Done" + if onboarding { + exitLabel = "Next" + } + items = append(items, optionItem{title: exitLabel, value: "back"}) + + desc := []string{ + "Add or update API keys for cloud providers.", + "Recommended: local models maximize privacy; for cloud quality, ElevenLabs is the top pick.", + "Tip: press / to filter.", + } + + screen := newListScreen(state, "Provider API Keys", desc, items, func(item optionItem) screen { + if item.value == "back" { + if onboarding && onNext != nil { + return onNext() + } + if onBack != nil { + return onBack() + } + return nil + } + return newProviderKeyFlow(state, item.value, func() screen { return newProvidersScreen(state, onBack, onNext, onboarding) }, func() screen { return newProvidersScreen(state, onBack, onNext, onboarding) }) + }, func() screen { + if onBack != nil { + return onBack() + } + state.cancelled = true + state.result = &ConfigureResult{Cancelled: true} + return nil + }) + screen.footer = "enter select • esc back • / filter" + return screen +} + +func newProviderKeyFlow(state *wizardState, providerName string, onContinue func() screen, onCancel func() screen) screen { + displayName := getProviderDisplayName(providerName) + if isProviderConfigured(state.cfg, providerName) { + masked := maskAPIKey(state.cfg.Providers[providerName].APIKey) + desc := []string{fmt.Sprintf("Current key: %s", masked)} + return newConfirmScreen(state, fmt.Sprintf("%s API Key", displayName), desc, "Update key", "Keep current", func() screen { + return newAPIKeyInputScreen(state, providerName, onContinue, onCancel) + }, func() screen { return onContinue() }) + } + return newAPIKeyInputScreen(state, providerName, onContinue, onCancel) +} + +func newAPIKeyInputScreen(state *wizardState, providerName string, onContinue func() screen, onCancel func() screen) screen { + p := provider.GetProvider(providerName) + displayName := getProviderDisplayName(providerName) + if p != nil { + if name, ok := providerDisplayNames[p.Name()]; ok { + displayName = name + } + } + desc := []string{fmt.Sprintf("Enter your %s API key", displayName)} + validate := func(s string) error { + if s == "" { + return fmt.Errorf("API key is required") + } + if p != nil && !p.ValidateAPIKey(s) { + return fmt.Errorf("invalid API key format for %s", displayName) + } + return nil + } + return newInputScreen(state, fmt.Sprintf("%s API Key", displayName), desc, "", "", true, validate, func(value string) screen { + if state.cfg.Providers == nil { + state.cfg.Providers = make(map[string]config.ProviderConfig) + } + state.cfg.Providers[providerName] = config.ProviderConfig{APIKey: value} + return onContinue() + }, onCancel) +} + +func newVoiceProviderScreen(state *wizardState, onBack func() screen, onNext func() screen) screen { + options := buildVoiceProviderOptions(state.cfg) + if len(options) == 0 { + desc := []string{ + "No voice model providers are available.", + "Configure a cloud provider API key or install whisper.cpp.", + } + s := newInfoScreen(state, "Voice Model Provider", desc, onBack, onBack) + s.footer = "enter back • esc back" + return s + } + + desc := []string{ + "Choose the provider for speech-to-text.", + "Recommended: local models maximize privacy; for cloud quality, ElevenLabs is the top pick.", + "Tip: press / to filter.", + } + + screen := newListScreen(state, "Voice Model Provider", desc, options, func(item optionItem) screen { + if item.value == "whisper-cpp-disabled" { + info := []string{ + "whisper-cli was not found in PATH.", + "Install whisper.cpp to use local transcription:", + "https://github.com/ggerganov/whisper.cpp", + } + s := newInfoScreen(state, "Whisper.cpp Not Found", info, func() screen { + return newVoiceProviderScreen(state, onBack, onNext) + }, func() screen { return newVoiceProviderScreen(state, onBack, onNext) }) + s.footer = "enter choose another • esc back" + return s + } + + selectedProvider := item.value + providerName := selectedProvider + switch selectedProvider { + case "groq-transcription", "groq-translation": + providerName = "groq" + case "mistral-transcription": + providerName = "mistral" + } + + if selectedProvider != "whisper-cpp" && !isProviderConfigured(state.cfg, providerName) { + return newProviderKeyFlow(state, providerName, func() screen { + return newVoiceModelScreen(state, selectedProvider, onBack, onNext) + }, func() screen { return newVoiceProviderScreen(state, onBack, onNext) }) + } + + return newVoiceModelScreen(state, selectedProvider, onBack, onNext) + }, func() screen { return onBack() }) + screen.footer = "enter select • esc back • / filter" + + if state.cfg.Transcription.Provider != "" { + selectListByValue(&screen.list, state.cfg.Transcription.Provider) + } + return screen +} + +func newVoiceModelScreen(state *wizardState, providerName string, onBack func() screen, onNext func() screen) screen { + options := getTranscriptionModelOptions(providerName) + if len(options) == 0 { + desc := []string{"No models available for this provider."} + s := newInfoScreen(state, "Voice Model", desc, onBack, onBack) + s.footer = "enter back • esc back" + return s + } + + items := make([]optionItem, 0, len(options)) + for _, opt := range options { + items = append(items, optionItem{title: opt.Label, value: opt.ID}) + } + + desc := []string{ + "Pick the model for speech-to-text.", + "Tip: press / to filter.", + } + screen := newListScreen(state, "Voice Model", desc, items, func(item optionItem) screen { + if item.value == "" { + return newVoiceModelScreen(state, providerName, onBack, onNext) + } + if providerName == "whisper-cpp" && !whisper.IsInstalled(item.value) { + modelInfo := whisper.GetModel(item.value) + if modelInfo == nil { + state.err = fmt.Errorf("unknown model: %s", item.value) + return nil + } + confirmDesc := []string{fmt.Sprintf("Download %s (%s)?", modelInfo.Name, modelInfo.Size)} + return newConfirmScreen(state, "Download Model", confirmDesc, "Download", "Cancel", func() screen { + return newDownloadScreen(state, "Downloading Model", []string{modelInfo.Name}, item.value, func() screen { + return applyVoiceModelSelection(state, providerName, item.value, onBack, onNext) + }, func() screen { return newVoiceModelScreen(state, providerName, onBack, onNext) }) + }, func() screen { return newVoiceModelScreen(state, providerName, onBack, onNext) }) + } + return applyVoiceModelSelection(state, providerName, item.value, onBack, onNext) + }, func() screen { return onBack() }) + screen.footer = "enter select • esc back • / filter" + + if state.cfg.Transcription.Model != "" { + selectListByValue(&screen.list, state.cfg.Transcription.Model) + } + return screen +} + +func applyVoiceModelSelection(state *wizardState, providerName, modelID string, onBack func() screen, onNext func() screen) screen { + state.cfg.Transcription.Provider = providerName + state.cfg.Transcription.Model = modelID + + registryName := mapConfigProviderToRegistry(providerName) + model, err := provider.GetModel(registryName, modelID) + if err != nil { + state.err = err + return nil + } + + if state.cfg.Transcription.Language != "" && !model.SupportsLanguage(state.cfg.Transcription.Language) { + state.cfg.Transcription.Language = "" + } + + if len(model.SupportedLanguages) <= 1 { + if len(model.SupportedLanguages) == 1 { + state.cfg.Transcription.Language = model.SupportedLanguages[0] + } else { + state.cfg.Transcription.Language = "" + } + return applyStreamingSelection(state, model, onNext) + } + + return newLanguageScreen(state, model, func() screen { + return newVoiceModelScreen(state, providerName, onBack, onNext) + }, func() screen { + return applyStreamingSelection(state, model, onNext) + }) +} + +func newLanguageScreen(state *wizardState, model *provider.Model, onBack func() screen, onNext func() screen) screen { + items := []optionItem{{title: "Auto-detect (recommended)", value: ""}} + for _, code := range model.SupportedLanguages { + items = append(items, optionItem{title: code, value: code}) + } + desc := []string{"Select the language for the voice model.", "Tip: press / to filter."} + screen := newListScreen(state, "Language", desc, items, func(item optionItem) screen { + state.cfg.Transcription.Language = item.value + return onNext() + }, func() screen { return onBack() }) + screen.footer = "enter select • esc back • / filter" + + if state.cfg.Transcription.Language != "" { + selectListByValue(&screen.list, state.cfg.Transcription.Language) + } + return screen +} + +func applyStreamingSelection(state *wizardState, model *provider.Model, next func() screen) screen { + if model.SupportsBothModes() { + desc := []string{"This model supports both batch and streaming modes."} + return newConfirmScreen(state, "Enable Streaming Mode?", desc, "Yes, streaming", "No, batch", func() screen { + state.cfg.Transcription.Streaming = true + return next() + }, func() screen { + state.cfg.Transcription.Streaming = false + return next() + }) + } + if model.SupportsStreaming { + state.cfg.Transcription.Streaming = true + } else { + state.cfg.Transcription.Streaming = false + } + return next() +} + +func newLLMEnableScreen(state *wizardState, onBack func() screen, onNext func() screen) screen { + desc := []string{"LLM post-processing cleans up grammar, punctuation, and filler words."} + if state.cfg.LLM.Enabled { + desc = []string{fmt.Sprintf("Currently enabled (%s/%s).", state.cfg.LLM.Provider, state.cfg.LLM.Model), desc[0]} + } else { + desc = []string{"Currently disabled.", desc[0]} + } + return newConfirmScreen(state, "Enable LLM Post-Processing?", desc, "Yes (recommended)", "No", func() screen { + return newLLMProviderScreen(state, onBack, onNext) + }, func() screen { + state.cfg.LLM.Enabled = false + return onNext() + }) +} + +func newLLMProviderScreen(state *wizardState, onBack func() screen, onNext func() screen) screen { + options := buildLLMProviderOptions(state.cfg) + if len(options) == 0 { + info := []string{"No LLM providers available.", "Configure OpenAI or Groq first."} + s := newInfoScreen(state, "LLM Provider", info, onBack, onBack) + s.footer = "enter back • esc back" + return s + } + + desc := []string{"Choose a provider for text post-processing.", "Tip: press / to filter."} + screen := newListScreen(state, "LLM Provider", desc, options, func(item optionItem) screen { + providerName := item.value + if !isProviderConfigured(state.cfg, providerName) { + return newProviderKeyFlow(state, providerName, func() screen { + return newLLMModelScreen(state, providerName, onBack, onNext) + }, func() screen { return newLLMProviderScreen(state, onBack, onNext) }) + } + return newLLMModelScreen(state, providerName, onBack, onNext) + }, func() screen { return onBack() }) + screen.footer = "enter select • esc back • / filter" + if state.cfg.LLM.Provider != "" { + selectListByValue(&screen.list, state.cfg.LLM.Provider) + } + return screen +} + +func newLLMModelScreen(state *wizardState, providerName string, onBack func() screen, onNext func() screen) screen { + p := provider.GetProvider(providerName) + if p == nil { + state.err = fmt.Errorf("unknown provider: %s", providerName) + return nil + } + models := provider.ModelsOfType(p, provider.LLM) + items := make([]optionItem, 0, len(models)) + for _, m := range models { + items = append(items, optionItem{title: fmt.Sprintf("%s (%s)", m.Name, m.Description), value: m.ID}) + } + desc := []string{"Choose the LLM model.", "Tip: press / to filter."} + screen := newListScreen(state, "LLM Model", desc, items, func(item optionItem) screen { + state.cfg.LLM.Provider = providerName + state.cfg.LLM.Model = item.value + return newPostProcessingScreen(state, onBack, onNext) + }, func() screen { return newLLMProviderScreen(state, onBack, onNext) }) + screen.footer = "enter select • esc back • / filter" + if state.cfg.LLM.Model != "" { + selectListByValue(&screen.list, state.cfg.LLM.Model) + } + return screen +} + +func newPostProcessingScreen(state *wizardState, onBack func() screen, onNext func() screen) screen { + current := state.cfg.LLM.PostProcessing + if !current.RemoveStutters && !current.AddPunctuation && !current.FixGrammar && !current.RemoveFillerWords { + current = config.LLMPostProcessingConfig{ + RemoveStutters: true, + AddPunctuation: true, + FixGrammar: true, + RemoveFillerWords: true, + } + } + + items := []toggleItem{ + {title: "Remove stutters (repeated words)", value: "stutters", selected: current.RemoveStutters}, + {title: "Add punctuation", value: "punctuation", selected: current.AddPunctuation}, + {title: "Fix grammar", value: "grammar", selected: current.FixGrammar}, + {title: "Remove filler words (um, uh, like)", value: "fillers", selected: current.RemoveFillerWords}, + } + + desc := []string{"Select which improvements to apply.", "Tip: press / to filter."} + screen := newMultiSelectScreen(state, "Post-Processing Options", desc, items, true, func(items []toggleItem) screen { + result := config.LLMPostProcessingConfig{} + for _, item := range items { + if !item.selected { + continue + } + switch item.value { + case "stutters": + result.RemoveStutters = true + case "punctuation": + result.AddPunctuation = true + case "grammar": + result.FixGrammar = true + case "fillers": + result.RemoveFillerWords = true + } + } + state.cfg.LLM.PostProcessing = result + return newCustomPromptConfirmScreen(state, onBack, onNext) + }, func() screen { return newLLMModelScreen(state, state.cfg.LLM.Provider, onBack, onNext) }) + screen.footer = "space toggle • enter save • esc back • / filter" + return screen +} + +func newCustomPromptConfirmScreen(state *wizardState, onBack func() screen, onNext func() screen) screen { + desc := []string{"Add extra instructions for the LLM."} + if state.cfg.LLM.CustomPrompt.Enabled && state.cfg.LLM.CustomPrompt.Prompt != "" { + preview := state.cfg.LLM.CustomPrompt.Prompt + if len(preview) > 40 { + preview = preview[:40] + "..." + } + desc = append([]string{fmt.Sprintf("Current prompt: \"%s\"", preview)}, desc...) + } else { + desc = append([]string{"Current prompt: none."}, desc...) + } + return newConfirmScreen(state, "Add Custom Prompt?", desc, "Yes", "No", func() screen { + return newInputScreen(state, "Custom Prompt", []string{"Additional instructions for the LLM."}, state.cfg.LLM.CustomPrompt.Prompt, "Format as bullet points", false, func(s string) error { + if len(s) > 500 { + return fmt.Errorf("prompt must be 500 characters or less") + } + return nil + }, func(value string) screen { + state.cfg.LLM.CustomPrompt.Enabled = true + state.cfg.LLM.CustomPrompt.Prompt = value + state.cfg.LLM.Enabled = true + return onNext() + }, func() screen { return newCustomPromptConfirmScreen(state, onBack, onNext) }) + }, func() screen { + state.cfg.LLM.CustomPrompt.Enabled = false + state.cfg.LLM.Enabled = true + return onNext() + }) +} + +func newKeywordsScreen(state *wizardState, onBack func() screen) screen { + desc := []string{ + "Comma-separated words to keep spelling accurate (names, acronyms, terms).", + "Used by LLM post-processing to preserve spelling and phrasing.", + } + initial := "" + if len(state.cfg.Keywords) > 0 { + initial = strings.Join(state.cfg.Keywords, ", ") + } + return newInputScreen(state, "Keywords", desc, initial, "e.g., Kubernetes, PostgreSQL, John Smith", false, nil, func(value string) screen { + if strings.TrimSpace(value) == "" { + state.cfg.Keywords = nil + } else { + parts := strings.Split(value, ",") + keywords := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + keywords = append(keywords, part) + } + } + state.cfg.Keywords = keywords + } + if state.onboarding { + return newInjectionScreen(state, func() screen { return newKeywordsScreen(state, onBack) }) + } + return onBack() + }, onBack) +} + +func newInjectionScreen(state *wizardState, onBack func() screen) screen { + selected := state.cfg.Injection.Backends + if len(selected) == 0 { + selected = []string{"ydotool", "wtype", "clipboard"} + } + selectedSet := make(map[string]bool, len(selected)) + for _, b := range selected { + selectedSet[b] = true + } + + items := []toggleItem{ + {title: "ydotool - best for Chromium/Electron (needs ydotoold)", value: "ydotool", selected: selectedSet["ydotool"]}, + {title: "wtype - native Wayland typing", value: "wtype", selected: selectedSet["wtype"]}, + {title: "clipboard - copy to clipboard only", value: "clipboard", selected: selectedSet["clipboard"]}, + } + desc := []string{"Backends are tried in order until one succeeds.", "Tip: press / to filter."} + screen := newMultiSelectScreen(state, "Text Injection Backends", desc, items, true, func(items []toggleItem) screen { + var backends []string + for _, item := range items { + if item.selected { + backends = append(backends, item.value) + } + } + state.cfg.Injection.Backends = backends + if state.onboarding { + return newNotificationsScreen(state, func() screen { return newInjectionScreen(state, onBack) }) + } + return onBack() + }, onBack) + screen.footer = "space toggle • enter save • esc back • / filter" + return screen +} + +func newNotificationsScreen(state *wizardState, onBack func() screen) screen { + desc := []string{"Show notifications for recording status changes."} + if state.cfg.Notifications.Enabled { + desc = append([]string{fmt.Sprintf("Currently enabled (%s).", state.cfg.Notifications.Type)}, desc...) + } else { + desc = append([]string{"Currently disabled."}, desc...) + } + return newConfirmScreen(state, "Enable Desktop Notifications?", desc, "Yes", "No", func() screen { + state.cfg.Notifications.Enabled = true + return newNotificationTypeScreen(state, onBack) + }, func() screen { + state.cfg.Notifications.Enabled = false + if state.onboarding { + return newAdvancedPromptScreen(state, onBack) + } + return onBack() + }) +} + +func newNotificationTypeScreen(state *wizardState, onBack func() screen) screen { + if state.cfg.Notifications.Type == "" { + state.cfg.Notifications.Type = "desktop" + } + items := []optionItem{ + {title: "Desktop notifications (notify-send)", value: "desktop"}, + {title: "Log to console only", value: "log"}, + {title: "None (silent)", value: "none"}, + } + desc := []string{"Choose how notifications should be displayed."} + screen := newListScreen(state, "Notification Type", desc, items, func(item optionItem) screen { + state.cfg.Notifications.Type = item.value + return newCustomMessagesConfirmScreen(state, onBack) + }, func() screen { return newNotificationsScreen(state, onBack) }) + screen.footer = "enter select • esc back • / filter" + selectListByValue(&screen.list, state.cfg.Notifications.Type) + return screen +} + +func newCustomMessagesConfirmScreen(state *wizardState, onBack func() screen) screen { + desc := []string{"Customize the text shown in notifications."} + return newConfirmScreen(state, "Customize Notification Messages?", desc, "Yes", "No", func() screen { + return newNotificationMessagesScreen(state, onBack) + }, func() screen { + if state.onboarding { + return newAdvancedPromptScreen(state, onBack) + } + return onBack() + }) +} + +func newNotificationMessagesScreen(state *wizardState, onBack func() screen) screen { + items := make([]optionItem, 0, len(notify.MessageDefs)+1) + for _, def := range notify.MessageDefs { + _, currentBody := getNotificationMessage(state.cfg, def) + display := currentBody + if display == "" { + display = def.DefaultBody + } + if len(display) > 40 { + display = display[:40] + "..." + } + label := fmt.Sprintf("%s: \"%s\"", def.ConfigKey, display) + items = append(items, optionItem{title: label, value: def.ConfigKey}) + } + items = append(items, optionItem{title: "Back", value: "back"}) + desc := []string{"Select a message to edit."} + screen := newListScreen(state, "Notification Messages", desc, items, func(item optionItem) screen { + if item.value == "back" { + if state.onboarding { + return newAdvancedPromptScreen(state, onBack) + } + return onBack() + } + return newNotificationMessageEditScreen(state, item.value, func() screen { return newNotificationMessagesScreen(state, onBack) }) + }, func() screen { return onBack() }) + screen.footer = "enter select • esc back • / filter" + return screen +} + +func newNotificationMessageEditScreen(state *wizardState, configKey string, onBack func() screen) screen { + def := findMessageDef(configKey) + if def == nil { + state.err = fmt.Errorf("unknown message: %s", configKey) + return nil + } + + currentTitle, currentBody := getNotificationMessage(state.cfg, *def) + if currentTitle == "" { + currentTitle = def.DefaultTitle + } + if currentBody == "" { + currentBody = def.DefaultBody + } + + fields := []formField{} + if !def.IsError { + fields = append(fields, makeInputField("title", "Title", fmt.Sprintf("Default: %s", def.DefaultTitle), currentTitle, def.DefaultTitle, nil)) + } + fields = append(fields, makeInputField("body", "Body", fmt.Sprintf("Default: %s", def.DefaultBody), currentBody, def.DefaultBody, nil)) + + screen := newFormScreen(state, "Edit Notification", nil, fields, func(values map[string]string) screen { + msg := config.MessageConfig{Title: values["title"], Body: values["body"]} + setNotificationMessage(state.cfg, configKey, msg) + return onBack() + }, onBack) + screen.footer = "enter save • esc back" + return screen +} + +func newAdvancedPromptScreen(state *wizardState, onBack func() screen) screen { + desc := []string{"Configure advanced settings like recording parameters and timeouts."} + return newConfirmScreen(state, "Configure Advanced Settings?", desc, "Yes", "No", func() screen { + return newAdvancedMenuScreen(state, onBack, true) + }, func() screen { + if state.onboarding { + return newMenuScreen(state) + } + return onBack() + }) +} + +func newAdvancedMenuScreen(state *wizardState, onBack func() screen, onboarding bool) screen { + items := []optionItem{ + {title: formatAdvancedRecordingLabel(state.cfg), value: "recording"}, + {title: formatAdvancedInjectionTimeoutLabel(state.cfg), value: "timeouts"}, + {title: "Back", value: "back"}, + } + if onboarding { + items[len(items)-1].title = "Next" + } + desc := []string{"Configure low-level options."} + screen := newListScreen(state, "Advanced Settings", desc, items, func(item optionItem) screen { + switch item.value { + case "recording": + return newRecordingSettingsScreen(state, func() screen { return newAdvancedMenuScreen(state, onBack, onboarding) }) + case "timeouts": + return newInjectionTimeoutsScreen(state, func() screen { return newAdvancedMenuScreen(state, onBack, onboarding) }) + case "back": + if onboarding { + return newMenuScreen(state) + } + return onBack() + default: + return newAdvancedMenuScreen(state, onBack, onboarding) + } + }, func() screen { return onBack() }) + screen.footer = "enter select • esc back • / filter" + return screen +} + +func newRecordingSettingsScreen(state *wizardState, onBack func() screen) screen { + cfg := state.cfg.Recording + fields := []formField{ + makeInputField("sample_rate", "Sample Rate (Hz)", "16000 is optimal for speech recognition.", strconv.Itoa(cfg.SampleRate), "16000", func(s string) error { + if _, err := strconv.Atoi(s); err != nil { + return fmt.Errorf("sample rate must be a number") + } + return nil + }), + makeInputField("channels", "Channels", "1 (mono) recommended.", strconv.Itoa(cfg.Channels), "1", func(s string) error { + v, err := strconv.Atoi(s) + if err != nil { + return fmt.Errorf("channels must be a number") + } + if v != 1 && v != 2 { + return fmt.Errorf("channels must be 1 or 2") + } + return nil + }), + makeInputField("format", "Audio Format", "Use s16 for most setups.", cfg.Format, "s16", func(s string) error { + if s != "s16" && s != "f32" { + return fmt.Errorf("format must be s16 or f32") + } + return nil + }), + makeInputField("buffer_size", "Buffer Size (bytes)", "Larger = less CPU, more latency.", strconv.Itoa(cfg.BufferSize), "8192", func(s string) error { + if _, err := strconv.Atoi(s); err != nil { + return fmt.Errorf("buffer size must be a number") + } + return nil + }), + makeInputField("channel_buffer", "Channel Buffer Size", "Number of audio frames to buffer.", strconv.Itoa(cfg.ChannelBufferSize), "30", func(s string) error { + if _, err := strconv.Atoi(s); err != nil { + return fmt.Errorf("channel buffer size must be a number") + } + return nil + }), + makeInputField("device", "Device", "Leave empty for default microphone.", cfg.Device, "(default)", nil), + makeInputField("timeout", "Recording Timeout", "Examples: 30s, 2m, 5m.", cfg.Timeout.String(), "5m", func(s string) error { + if _, err := time.ParseDuration(s); err != nil { + return fmt.Errorf("invalid duration format") + } + return nil + }), + } + screen := newFormScreen(state, "Recording Settings", nil, fields, func(values map[string]string) screen { + state.cfg.Recording.SampleRate, _ = strconv.Atoi(values["sample_rate"]) + state.cfg.Recording.Channels, _ = strconv.Atoi(values["channels"]) + state.cfg.Recording.Format = values["format"] + state.cfg.Recording.BufferSize, _ = strconv.Atoi(values["buffer_size"]) + state.cfg.Recording.ChannelBufferSize, _ = strconv.Atoi(values["channel_buffer"]) + state.cfg.Recording.Device = values["device"] + state.cfg.Recording.Timeout, _ = time.ParseDuration(values["timeout"]) + return onBack() + }, onBack) + screen.footer = "enter save • esc back" + return screen +} + +func newInjectionTimeoutsScreen(state *wizardState, onBack func() screen) screen { + cfg := state.cfg.Injection + fields := []formField{ + makeInputField("ydotool", "ydotool Timeout", "Examples: 5s, 10s.", cfg.YdotoolTimeout.String(), "5s", func(s string) error { + if _, err := time.ParseDuration(s); err != nil { + return fmt.Errorf("invalid duration format") + } + return nil + }), + makeInputField("wtype", "wtype Timeout", "Examples: 5s, 10s.", cfg.WtypeTimeout.String(), "5s", func(s string) error { + if _, err := time.ParseDuration(s); err != nil { + return fmt.Errorf("invalid duration format") + } + return nil + }), + makeInputField("clipboard", "Clipboard Timeout", "Examples: 3s, 5s.", cfg.ClipboardTimeout.String(), "3s", func(s string) error { + if _, err := time.ParseDuration(s); err != nil { + return fmt.Errorf("invalid duration format") + } + return nil + }), + } + screen := newFormScreen(state, "Injection Timeouts", nil, fields, func(values map[string]string) screen { + state.cfg.Injection.YdotoolTimeout, _ = time.ParseDuration(values["ydotool"]) + state.cfg.Injection.WtypeTimeout, _ = time.ParseDuration(values["wtype"]) + state.cfg.Injection.ClipboardTimeout, _ = time.ParseDuration(values["clipboard"]) + return onBack() + }, onBack) + screen.footer = "enter save • esc back" + return screen +} + +func newSummaryScreen(state *wizardState, onBack func() screen) screen { + summary := buildSummaryLines(state.cfg) + items := []optionItem{ + {title: "Save", value: "save"}, + {title: "Cancel", value: "cancel"}, + } + desc := []string{} + desc = append(desc, summary...) + screen := &summaryScreen{ + state: state, + title: "Configuration Summary", + desc: desc, + list: newSummaryList(items), + onSave: func() screen { state.result = &ConfigureResult{Config: state.cfg, Cancelled: false}; return nil }, + onBack: onBack, + } + screen.footer = "enter save • esc back" + return screen +} + +func buildVoiceProviderOptions(cfg *config.Config) []optionItem { + var options []optionItem + + whisperStatus := deps.CheckWhisperCli() + if whisperStatus.Installed { + options = append(options, optionItem{title: "Whisper.cpp (local, no API key)", value: "whisper-cpp"}) + } else { + options = append(options, optionItem{title: "Whisper.cpp (local, install required)", value: "whisper-cpp-disabled"}) + } + + configured := getConfiguredProviders(cfg) + for _, name := range configured { + p := provider.GetProvider(name) + if p != nil && len(provider.ModelsOfType(p, provider.Transcription)) > 0 { + switch name { + case "openai": + options = append(options, optionItem{title: "OpenAI Whisper", value: "openai"}) + case "groq": + options = append(options, + optionItem{title: "Groq Whisper (transcription)", value: "groq-transcription"}, + optionItem{title: "Groq Whisper (translate to English)", value: "groq-translation"}, + ) + case "mistral": + options = append(options, optionItem{title: "Mistral Voxtral", value: "mistral-transcription"}) + case "elevenlabs": + options = append(options, optionItem{title: "ElevenLabs Scribe", value: "elevenlabs"}) + case "deepgram": + options = append(options, optionItem{title: "Deepgram Nova", value: "deepgram"}) + } + } + } + + configuredSet := make(map[string]bool) + for _, name := range configured { + configuredSet[name] = true + } + + if !configuredSet["openai"] { + options = append(options, optionItem{title: "OpenAI Whisper (add API key)", value: "openai"}) + } + if !configuredSet["groq"] { + options = append(options, + optionItem{title: "Groq Whisper transcription (add API key)", value: "groq-transcription"}, + optionItem{title: "Groq Whisper translation (add API key)", value: "groq-translation"}, + ) + } + if !configuredSet["mistral"] { + options = append(options, optionItem{title: "Mistral Voxtral (add API key)", value: "mistral-transcription"}) + } + if !configuredSet["elevenlabs"] { + options = append(options, optionItem{title: "ElevenLabs Scribe (add API key)", value: "elevenlabs"}) + } + if !configuredSet["deepgram"] { + options = append(options, optionItem{title: "Deepgram Nova (add API key)", value: "deepgram"}) + } + + return options +} + +func buildLLMProviderOptions(cfg *config.Config) []optionItem { + var options []optionItem + configured := getConfiguredProviders(cfg) + for _, name := range configured { + p := provider.GetProvider(name) + if p != nil && len(provider.ModelsOfType(p, provider.LLM)) > 0 { + switch name { + case "openai": + options = append(options, optionItem{title: "OpenAI GPT", value: "openai"}) + case "groq": + options = append(options, optionItem{title: "Groq Llama (fast)", value: "groq"}) + } + } + } + + configuredSet := make(map[string]bool) + for _, name := range configured { + configuredSet[name] = true + } + if !configuredSet["openai"] { + options = append(options, optionItem{title: "OpenAI GPT (add API key)", value: "openai"}) + } + if !configuredSet["groq"] { + options = append(options, optionItem{title: "Groq Llama (add API key)", value: "groq"}) + } + + return options +} + +func formatProviderOption(cfg *config.Config, name string) string { + status := "(not configured)" + if pc, exists := cfg.Providers[name]; exists && pc.APIKey != "" { + status = "(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) + case "deepgram": + return fmt.Sprintf("Deepgram - Nova %s", status) + default: + return fmt.Sprintf("%s %s", name, status) + } +} + +func formatProvidersLabel(cfg *config.Config) string { + count := len(getConfiguredProviders(cfg)) + if count == 0 { + return "Providers (none)" + } + return fmt.Sprintf("Providers (%d configured)", count) +} + +func formatVoiceModelLabel(cfg *config.Config) string { + if cfg.Transcription.Provider == "" || cfg.Transcription.Model == "" { + return "Voice Model (not set)" + } + return fmt.Sprintf("Voice Model (%s/%s)", cfg.Transcription.Provider, cfg.Transcription.Model) +} + +func formatLLMLabel(cfg *config.Config) string { + if !cfg.LLM.Enabled { + return "LLM (disabled)" + } + if cfg.LLM.Provider == "" || cfg.LLM.Model == "" { + return "LLM (enabled)" + } + return fmt.Sprintf("LLM (%s/%s)", cfg.LLM.Provider, cfg.LLM.Model) +} + +func formatKeywordsLabel(cfg *config.Config) string { + if len(cfg.Keywords) == 0 { + return "Keywords (none)" + } + return fmt.Sprintf("Keywords (%d)", len(cfg.Keywords)) +} + +func formatInjectionLabel(cfg *config.Config) string { + if len(cfg.Injection.Backends) == 0 { + return "Injection (none)" + } + return fmt.Sprintf("Injection (%s)", strings.Join(cfg.Injection.Backends, " -> ")) +} + +func formatNotificationsLabel(cfg *config.Config) string { + if !cfg.Notifications.Enabled { + return "Notifications (disabled)" + } + if cfg.Notifications.Type == "" { + return "Notifications (enabled)" + } + return fmt.Sprintf("Notifications (%s)", cfg.Notifications.Type) +} + +func formatAdvancedRecordingLabel(cfg *config.Config) string { + return fmt.Sprintf("Recording Settings (rate=%d, timeout=%s)", cfg.Recording.SampleRate, cfg.Recording.Timeout) +} + +func formatAdvancedInjectionTimeoutLabel(cfg *config.Config) string { + return fmt.Sprintf("Injection Timeouts (ydotool=%s, wtype=%s, clipboard=%s)", cfg.Injection.YdotoolTimeout, cfg.Injection.WtypeTimeout, cfg.Injection.ClipboardTimeout) +} + +func getNotificationMessage(cfg *config.Config, def notify.MessageDef) (string, string) { + switch def.ConfigKey { + case "recording_started": + return cfg.Notifications.Messages.RecordingStarted.Title, cfg.Notifications.Messages.RecordingStarted.Body + case "transcribing": + return cfg.Notifications.Messages.Transcribing.Title, cfg.Notifications.Messages.Transcribing.Body + case "llm_processing": + return cfg.Notifications.Messages.LLMProcessing.Title, cfg.Notifications.Messages.LLMProcessing.Body + case "config_reloaded": + return cfg.Notifications.Messages.ConfigReloaded.Title, cfg.Notifications.Messages.ConfigReloaded.Body + case "operation_cancelled": + return cfg.Notifications.Messages.OperationCancelled.Title, cfg.Notifications.Messages.OperationCancelled.Body + case "recording_aborted": + return cfg.Notifications.Messages.RecordingAborted.Title, cfg.Notifications.Messages.RecordingAborted.Body + case "injection_aborted": + return cfg.Notifications.Messages.InjectionAborted.Title, cfg.Notifications.Messages.InjectionAborted.Body + default: + return "", "" + } +} + +func setNotificationMessage(cfg *config.Config, configKey string, msg config.MessageConfig) { + switch configKey { + case "recording_started": + cfg.Notifications.Messages.RecordingStarted = msg + case "transcribing": + cfg.Notifications.Messages.Transcribing = msg + case "llm_processing": + cfg.Notifications.Messages.LLMProcessing = msg + case "config_reloaded": + cfg.Notifications.Messages.ConfigReloaded = msg + case "operation_cancelled": + cfg.Notifications.Messages.OperationCancelled = msg + case "recording_aborted": + cfg.Notifications.Messages.RecordingAborted = msg + case "injection_aborted": + cfg.Notifications.Messages.InjectionAborted = msg + } +} + +func findMessageDef(key string) *notify.MessageDef { + for _, def := range notify.MessageDefs { + if def.ConfigKey == key { + return &def + } + } + return nil +} + +func buildSummaryLines(cfg *config.Config) []string { + var lines []string + + providers := getConfiguredProviders(cfg) + providerSummary := "none" + if len(providers) > 0 { + providerSummary = strings.Join(providers, ", ") + } + lines = append(lines, fmt.Sprintf("Providers: %s", providerSummary)) + + lang := cfg.Transcription.Language + if lang == "" { + lang = "auto-detect" + } + voiceSummary := "not set" + if cfg.Transcription.Provider != "" && cfg.Transcription.Model != "" { + voiceSummary = fmt.Sprintf("%s/%s (%s)", cfg.Transcription.Provider, cfg.Transcription.Model, lang) + } + lines = append(lines, fmt.Sprintf("Voice Model: %s", voiceSummary)) + + if cfg.LLM.Enabled { + lines = append(lines, fmt.Sprintf("LLM: %s (%s)", cfg.LLM.Provider, cfg.LLM.Model)) + var opts []string + if cfg.LLM.PostProcessing.RemoveStutters { + opts = append(opts, "remove stutters") + } + if cfg.LLM.PostProcessing.AddPunctuation { + opts = append(opts, "add punctuation") + } + if cfg.LLM.PostProcessing.FixGrammar { + opts = append(opts, "fix grammar") + } + if cfg.LLM.PostProcessing.RemoveFillerWords { + opts = append(opts, "remove fillers") + } + if len(opts) > 0 { + lines = append(lines, fmt.Sprintf("Post-processing: %s", strings.Join(opts, ", "))) + } + } else { + lines = append(lines, "LLM: disabled") + } + + if len(cfg.Keywords) > 0 { + lines = append(lines, fmt.Sprintf("Keywords: %s", strings.Join(cfg.Keywords, ", "))) + } + + backendSummary := "none" + if len(cfg.Injection.Backends) > 0 { + backendSummary = strings.Join(cfg.Injection.Backends, " -> ") + } + lines = append(lines, fmt.Sprintf("Backends: %s", backendSummary)) + + notifSummary := "disabled" + if cfg.Notifications.Enabled { + if cfg.Notifications.Type != "" { + notifSummary = fmt.Sprintf("enabled (%s)", cfg.Notifications.Type) + } else { + notifSummary = "enabled" + } + } + lines = append(lines, fmt.Sprintf("Notifications: %s", notifSummary)) + + return lines +} + +func selectListByValue(l *list.Model, value string) { + if value == "" { + return + } + items := l.Items() + for i, item := range items { + switch v := item.(type) { + case optionItem: + if v.value == value { + l.Select(i) + return + } + case toggleItem: + if v.value == value { + l.Select(i) + return + } + } + } +} + +func newSummaryList(items []optionItem) list.Model { + delegate := list.NewDefaultDelegate() + l := list.New(itemsToList(items), delegate, 0, 0) + l.DisableQuitKeybindings() + l.SetShowHelp(false) + l.SetFilteringEnabled(false) + l.SetShowStatusBar(false) + return l +} + +type summaryScreen struct { + state *wizardState + title string + desc []string + list list.Model + footer string + onSave func() screen + onBack func() screen +} + +func (s *summaryScreen) Init() tea.Cmd { return nil } + +func (s *summaryScreen) Update(msg tea.Msg) (screen, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.list.SetSize(msg.Width-4, msg.Height-10) + case tea.KeyMsg: + switch msg.String() { + case "enter": + if item, ok := s.list.SelectedItem().(optionItem); ok { + if item.value == "save" && s.onSave != nil { + return s.onSave(), nil + } + if item.value == "cancel" && s.onBack != nil { + return s.onBack(), nil + } + } + case "esc", "q": + if s.onBack != nil { + return s.onBack(), nil + } + } + } + + var cmd tea.Cmd + s.list, cmd = s.list.Update(msg) + return s, cmd +} + +func (s *summaryScreen) View() string { + header := renderHeader(s.title, nil, "") + var body strings.Builder + for _, line := range s.desc { + body.WriteString(StyleLabel.Render(line)) + body.WriteString("\n") + } + body.WriteString("\n") + footer := renderFooter(s.footer, false) + return header + body.String() + s.list.View() + "\n" + footer +} + +func downloadWhisperModel(modelID string, onProgress func(downloaded, total int64)) error { + return whisper.Download(context.Background(), modelID, onProgress) +} diff --git a/internal/tui/helpers.go b/internal/tui/helpers.go new file mode 100644 index 0000000..adcb9b1 --- /dev/null +++ b/internal/tui/helpers.go @@ -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 +} diff --git a/internal/tui/languages.go b/internal/tui/languages.go deleted file mode 100644 index ca2d33b..0000000 --- a/internal/tui/languages.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/screens.go b/internal/tui/screens.go new file mode 100644 index 0000000..58bda94 --- /dev/null +++ b/internal/tui/screens.go @@ -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) +} diff --git a/internal/tui/types.go b/internal/tui/types.go new file mode 100644 index 0000000..bed04f8 --- /dev/null +++ b/internal/tui/types.go @@ -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 +} diff --git a/internal/tui/wizard.go b/internal/tui/wizard.go new file mode 100644 index 0000000..6ea0832 --- /dev/null +++ b/internal/tui/wizard.go @@ -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 +} diff --git a/internal/tui/wizard_test.go b/internal/tui/wizard_test.go new file mode 100644 index 0000000..92c1b70 --- /dev/null +++ b/internal/tui/wizard_test.go @@ -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()) + } +}