From 55257db72583c4b2436e9a00ee80c1340b8df444 Mon Sep 17 00:00:00 2001 From: leonardotrapani Date: Sun, 1 Feb 2026 13:28:09 +0100 Subject: [PATCH] add batch/streaming section headers to model picker --- internal/tui/configure_transcription.go | 41 ++++++- internal/tui/configure_transcription_test.go | 115 +++++++++++++++++++ progress.txt | 9 ++ tasks/prd.jsonc | 2 +- 4 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 internal/tui/configure_transcription_test.go diff --git a/internal/tui/configure_transcription.go b/internal/tui/configure_transcription.go index 1aa2098..09d261f 100644 --- a/internal/tui/configure_transcription.go +++ b/internal/tui/configure_transcription.go @@ -123,7 +123,13 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri modelOptions := getTranscriptionModelOptions(selectedProvider, effectiveLanguage) selectedModel := cfg.Transcription.Model if selectedModel == "" && len(modelOptions) > 0 { - selectedModel = modelOptions[0].Value + // skip header options (empty value) to find first real model + for _, opt := range modelOptions { + if opt.Value != "" { + selectedModel = opt.Value + break + } + } } modelDesc := "" @@ -145,6 +151,11 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri return configuredProviders, err } + // if user selected a section header (empty value), re-prompt + if selectedModel == "" { + return editTranscription(cfg, configuredProviders) + } + // validate language-model compatibility before saving registryName := mapConfigProviderToRegistry(selectedProvider) if err := provider.ValidateModelLanguage(registryName, selectedModel, effectiveLanguage); err != nil { @@ -280,12 +291,26 @@ func getTranscriptionModelOptions(configProvider string, currentLang string) []h } models := provider.ModelsOfType(p, provider.Transcription) + + // separate batch and streaming models + var batchModels, streamingModels []provider.Model + for _, m := range models { + if m.Streaming { + streamingModels = append(streamingModels, m) + } else { + batchModels = append(batchModels, m) + } + } + var options []huh.Option[string] - for _, m := range models { + // add batch models first (with header if we have both types) + hasBoth := len(batchModels) > 0 && len(streamingModels) > 0 + if hasBoth && len(batchModels) > 0 { + options = append(options, huh.NewOption("─── Batch ───", "")) + } + for _, m := range batchModels { label := buildModelLabel(m, currentLang) - - // for local models, show installed status if m.Local && registryName == "whisper-cpp" { if whisper.IsInstalled(m.ID) { label = "[x] " + label @@ -293,7 +318,15 @@ func getTranscriptionModelOptions(configProvider string, currentLang string) []h label = "[ ] " + label } } + options = append(options, huh.NewOption(label, m.ID)) + } + // add streaming models (with header if we have both types) + if hasBoth && len(streamingModels) > 0 { + options = append(options, huh.NewOption("─── Streaming ───", "")) + } + for _, m := range streamingModels { + label := buildModelLabel(m, currentLang) options = append(options, huh.NewOption(label, m.ID)) } diff --git a/internal/tui/configure_transcription_test.go b/internal/tui/configure_transcription_test.go new file mode 100644 index 0000000..203dea6 --- /dev/null +++ b/internal/tui/configure_transcription_test.go @@ -0,0 +1,115 @@ +package tui + +import ( + "testing" + + "github.com/leonardotrapani/hyprvoice/internal/provider" +) + +func TestGetTranscriptionModelOptions_GroupsModels(t *testing.T) { + // test elevenlabs - has both batch and streaming + options := getTranscriptionModelOptions("elevenlabs", "") + + // find headers + var batchHeaderIdx, streamingHeaderIdx int + batchHeaderIdx = -1 + streamingHeaderIdx = -1 + + for i, opt := range options { + if opt.Value == "" { + if opt.Key == "─── Batch ───" { + batchHeaderIdx = i + } + if opt.Key == "─── Streaming ───" { + streamingHeaderIdx = i + } + } + } + + if batchHeaderIdx == -1 { + t.Error("expected Batch header for provider with both types") + } + if streamingHeaderIdx == -1 { + t.Error("expected Streaming header for provider with both types") + } + if batchHeaderIdx >= streamingHeaderIdx { + t.Errorf("Batch header should come before Streaming header: batch=%d, streaming=%d", batchHeaderIdx, streamingHeaderIdx) + } + + // verify models are grouped correctly + for i, opt := range options { + if opt.Value == "" { + continue // skip headers + } + model, _, _ := provider.FindModelByID(opt.Value) + if model == nil { + continue // unknown model + } + + if i < streamingHeaderIdx && model.Streaming { + t.Errorf("streaming model %s found before streaming header", opt.Value) + } + if i > streamingHeaderIdx && !model.Streaming { + t.Errorf("batch model %s found after streaming header", opt.Value) + } + } +} + +func TestGetTranscriptionModelOptions_NoHeadersForSingleType(t *testing.T) { + // test groq - batch only (no streaming models) + options := getTranscriptionModelOptions("groq-transcription", "") + + for _, opt := range options { + if opt.Value == "" { + t.Errorf("expected no headers for provider with only one model type, got: %s", opt.Key) + } + } +} + +func TestGetTranscriptionModelOptions_OpenAI_GroupsCorrectly(t *testing.T) { + options := getTranscriptionModelOptions("openai", "") + + var batchHeaderIdx, streamingHeaderIdx int + batchHeaderIdx = -1 + streamingHeaderIdx = -1 + + for i, opt := range options { + if opt.Value == "" { + if opt.Key == "─── Batch ───" { + batchHeaderIdx = i + } + if opt.Key == "─── Streaming ───" { + streamingHeaderIdx = i + } + } + } + + // OpenAI has 3 batch + 1 streaming + if batchHeaderIdx == -1 { + t.Error("expected Batch header for OpenAI") + } + if streamingHeaderIdx == -1 { + t.Error("expected Streaming header for OpenAI") + } + + // count models (not headers) by position + batchCount := 0 + streamingCount := 0 + for i, opt := range options { + if opt.Value == "" { + continue // skip headers + } + if i > batchHeaderIdx && i < streamingHeaderIdx { + batchCount++ + } else if i > streamingHeaderIdx { + streamingCount++ + } + } + + if batchCount < 3 { + t.Errorf("expected at least 3 batch models for OpenAI, got %d", batchCount) + } + if streamingCount < 1 { + t.Errorf("expected at least 1 streaming model for OpenAI, got %d", streamingCount) + } +} diff --git a/progress.txt b/progress.txt index 8ac368b..ad47bf7 100644 --- a/progress.txt +++ b/progress.txt @@ -562,4 +562,13 @@ Started: Sun Feb 1 12:22:47 AM CET 2026 - Removed `if m.Streaming { continue }` filter from `getTranscriptionModelOptions()` - Streaming models now appear in model picker: scribe_v1-streaming, scribe_v2-streaming, nova-3, nova-2, gpt-4o-realtime-preview - `buildModelLabel()` already adds `[streaming]` tag (lines 324-327) +- All tests passing, typecheck passes + +### Task 8: Add streaming section header in model picker +- Updated `getTranscriptionModelOptions()` to separate batch and streaming models +- Added `─── Batch ───` and `─── Streaming ───` headers when provider has both types +- Headers use empty string value, selecting header re-prompts user +- Default selection skips headers to find first real model +- Providers with only one type (e.g., Groq=batch, Deepgram=streaming) show no headers +- Added unit tests: GroupsModels, NoHeadersForSingleType, OpenAI_GroupsCorrectly - All tests passing, typecheck passes \ No newline at end of file diff --git a/tasks/prd.jsonc b/tasks/prd.jsonc index 0a6c11c..12d0c68 100644 --- a/tasks/prd.jsonc +++ b/tasks/prd.jsonc @@ -130,7 +130,7 @@ "Clear visual distinction between batch and streaming sections", "Typecheck passes" ], - "passes": false + "passes": true }, { "title": "Add docs URLs to provider models",