Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
1250 lines
46 KiB
Go
1250 lines
46 KiB
Go
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"
|
|
menuNotifications = "notifications"
|
|
menuAdvanced = "advanced"
|
|
menuSave = "save"
|
|
menuDiscard = "discard"
|
|
repoURL = "https://github.com/leonardotrapani/hyprvoice"
|
|
)
|
|
|
|
func newWelcomeScreen(state *wizardState) screen {
|
|
desc := append([]string{}, LogoLines()...)
|
|
desc = append(desc, "", "Voice-powered typing for Wayland/Hyprland.", "Let's set up your configuration.")
|
|
desc = append(desc, "Consider starring the project on GitHub ⭐")
|
|
desc = append(desc, repoURL)
|
|
s := newInfoScreen(state, "Hyprvoice Onboarding", desc, func() screen {
|
|
return onboardingVoiceProviderScreen(state)
|
|
}, func() screen {
|
|
state.cancelled = true
|
|
state.result = &ConfigureResult{Cancelled: true}
|
|
return nil
|
|
})
|
|
s.footer = "enter start • esc quit"
|
|
return s
|
|
}
|
|
|
|
func onboardingVoiceProviderScreen(state *wizardState) screen {
|
|
return newVoiceProviderScreen(state,
|
|
func() screen { return newWelcomeScreen(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 onboardingSummaryScreen(state *wizardState, onBack func() screen) screen {
|
|
return newSummaryScreen(state, func() screen {
|
|
return newNotificationsScreen(state, onBack)
|
|
})
|
|
}
|
|
|
|
func newMenuScreen(state *wizardState) screen {
|
|
items := []optionItem{
|
|
{title: "Save & Exit", desc: "Write config changes to disk.", value: menuSave},
|
|
{title: formatVoiceModelLabel(state.cfg), desc: "Pick the transcription provider, model, and language.", value: menuVoiceModel},
|
|
{title: formatLLMLabel(state.cfg), desc: "Configure post-processing and custom prompts.", value: menuLLM},
|
|
{title: formatKeywordsLabel(state.cfg), desc: "Words to preserve spelling and phrasing.", value: menuKeywords},
|
|
{title: formatProvidersLabel(state.cfg), desc: "Manage API keys for cloud providers.", value: menuProviders},
|
|
{title: formatNotificationsLabel(state.cfg), desc: "Notification type and message text.", value: menuNotifications},
|
|
{title: "Advanced Settings", desc: "Recording, injection, and timeout settings.", value: menuAdvanced},
|
|
{title: "Discard & Exit", desc: "Exit without saving changes.", value: menuDiscard},
|
|
}
|
|
|
|
desc := append([]string{}, LogoLines()...)
|
|
desc = append(desc, "", "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 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 = fmt.Sprintf("enter select • esc cancel • / filter\nconsider starring the project on github ⭐\n%s", repoURL)
|
|
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),
|
|
desc: formatProviderOptionDesc(state.cfg, name),
|
|
value: name,
|
|
})
|
|
}
|
|
|
|
exitLabel := "Done"
|
|
exitDesc := "Return to menu."
|
|
if onboarding {
|
|
exitLabel = "Next"
|
|
exitDesc = "Continue to voice model setup."
|
|
}
|
|
items = append(items, optionItem{title: exitLabel, desc: exitDesc, value: "back"})
|
|
|
|
desc := []string{
|
|
"Add or update API keys for cloud providers.",
|
|
"Recommended: 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", "Replace the stored API key.", "Keep current", "Keep the existing API key.", func() screen {
|
|
return newAPIKeyInputScreen(state, providerName, onContinue, onCancel)
|
|
}, func() screen { return onContinue() }, onCancel)
|
|
}
|
|
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)}
|
|
if url := getProviderKeyURL(providerName); url != "" {
|
|
desc = append(desc, fmt.Sprintf("Get key: %s", url))
|
|
}
|
|
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":
|
|
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.Title, desc: opt.Desc, 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", "Download and install the model.", "Cancel", "Return to model list.", 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) }, 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
|
|
backToModels := func() screen { return newVoiceModelScreen(state, providerName, onBack, onNext) }
|
|
|
|
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, backToModels, onNext)
|
|
}
|
|
|
|
return newLanguageScreen(state, model, func() screen {
|
|
return newVoiceModelScreen(state, providerName, onBack, onNext)
|
|
}, func() screen {
|
|
return applyStreamingSelection(state, model, backToModels, onNext)
|
|
})
|
|
}
|
|
|
|
func newLanguageScreen(state *wizardState, model *provider.Model, onBack func() screen, onNext func() screen) screen {
|
|
items := []optionItem{{title: "Auto-detect", desc: "Recommended. Let the model detect language.", value: ""}}
|
|
for _, code := range model.SupportedLanguages {
|
|
label := provider.LanguageLabel(code)
|
|
if label == "" {
|
|
label = code
|
|
}
|
|
items = append(items, optionItem{title: label, desc: fmt.Sprintf("Language code: %s", 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, onBack func() screen, next func() screen) screen {
|
|
if model.SupportsBothModes() {
|
|
desc := []string{
|
|
"This model supports both batch and streaming modes.",
|
|
"Streaming is quicker but more expensive.",
|
|
}
|
|
return newConfirmScreen(state, "Enable Streaming Mode?", desc, "Yes, streaming", "Quicker response, higher cost.", "No, batch", "Wait for full transcription (cheaper).", func() screen {
|
|
state.cfg.Transcription.Streaming = true
|
|
return next()
|
|
}, func() screen {
|
|
state.cfg.Transcription.Streaming = false
|
|
return next()
|
|
}, onBack)
|
|
}
|
|
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", "Higher quality output, takes longer to process.", "No", "Faster results, may need minor touch-ups.", func() screen {
|
|
return newLLMProviderScreen(state, onBack, onNext)
|
|
}, func() screen {
|
|
state.cfg.LLM.Enabled = false
|
|
return onNext()
|
|
}, onBack)
|
|
}
|
|
|
|
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 {
|
|
desc := m.Description
|
|
if desc == "" {
|
|
desc = fmt.Sprintf("Model id: %s", m.ID)
|
|
} else {
|
|
desc = fmt.Sprintf("%s (id: %s)", desc, m.ID)
|
|
}
|
|
items = append(items, optionItem{title: m.Name, desc: desc, 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", desc: "Remove repeated words in speech.", value: "stutters", selected: current.RemoveStutters},
|
|
{title: "Add punctuation", desc: "Insert commas and sentence breaks.", value: "punctuation", selected: current.AddPunctuation},
|
|
{title: "Fix grammar", desc: "Correct basic grammatical errors.", value: "grammar", selected: current.FixGrammar},
|
|
{title: "Remove filler words", desc: "Remove fillers like 'um' and '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...)
|
|
}
|
|
prev := func() screen { return newPostProcessingScreen(state, onBack, onNext) }
|
|
return newConfirmScreen(state, "Add Custom Prompt?", desc, "Yes", "Provide additional instructions.", "No", "Use default behavior only.", 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()
|
|
}, prev)
|
|
}
|
|
|
|
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 newNotificationsScreen(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", desc: "Best for Chromium/Electron. Requires ydotoold.", value: "ydotool", selected: selectedSet["ydotool"]},
|
|
{title: "wtype", desc: "Native Wayland typing.", value: "wtype", selected: selectedSet["wtype"]},
|
|
{title: "clipboard", desc: "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
|
|
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", "Show status notifications.", "No", "Disable notifications.", func() screen {
|
|
state.cfg.Notifications.Enabled = true
|
|
if state.cfg.Notifications.Type == "none" || state.cfg.Notifications.Type == "" {
|
|
state.cfg.Notifications.Type = "desktop"
|
|
}
|
|
if state.onboarding {
|
|
return onboardingSummaryScreen(state, onBack)
|
|
}
|
|
return newNotificationTypeScreen(state, onBack)
|
|
}, func() screen {
|
|
state.cfg.Notifications.Enabled = false
|
|
if state.onboarding {
|
|
state.cfg.Notifications.Type = "none"
|
|
return onboardingSummaryScreen(state, onBack)
|
|
}
|
|
return onBack()
|
|
}, onBack)
|
|
}
|
|
|
|
func newNotificationTypeScreen(state *wizardState, onBack func() screen) screen {
|
|
if state.cfg.Notifications.Type == "" {
|
|
state.cfg.Notifications.Type = "desktop"
|
|
}
|
|
items := []optionItem{
|
|
{title: "Recommended: Desktop notifications", desc: "Uses notify-send to show popups.", value: "desktop"},
|
|
{title: "Log to console", desc: "Only use for development, or if you want to plug it to something else. Write status changes to logs only.", value: "log"},
|
|
{title: "None", desc: "Disable notifications entirely.", 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."}
|
|
prev := func() screen { return newNotificationTypeScreen(state, onBack) }
|
|
return newConfirmScreen(state, "Customize Notification Messages?", desc, "Yes", "Edit titles and bodies.", "No", "Use default messages.", func() screen {
|
|
return newNotificationMessagesScreen(state, onBack)
|
|
}, func() screen {
|
|
if state.onboarding {
|
|
return onboardingSummaryScreen(state, onBack)
|
|
}
|
|
return onBack()
|
|
}, prev)
|
|
}
|
|
|
|
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 := formatNotificationMessageTitle(def)
|
|
desc := fmt.Sprintf("Current: \"%s\"", display)
|
|
items = append(items, optionItem{title: label, desc: desc, value: def.ConfigKey})
|
|
}
|
|
items = append(items, optionItem{title: "Done", desc: "Return to menu.", value: "back"})
|
|
desc := []string{"Select a message to edit."}
|
|
backFn := onBack
|
|
if state.onboarding {
|
|
backFn = func() screen { return onboardingSummaryScreen(state, onBack) }
|
|
}
|
|
screen := newListScreen(state, "Notification Messages", desc, items, func(item optionItem) screen {
|
|
if item.value == "back" {
|
|
return backFn()
|
|
}
|
|
return newNotificationMessageEditScreen(state, item.value, func() screen { return newNotificationMessagesScreen(state, onBack) })
|
|
}, func() screen { return backFn() })
|
|
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, injection, and timeouts."}
|
|
return newConfirmScreen(state, "Configure Advanced Settings?", desc, "Yes", "Edit recording, injection, and timeout values.", "No", "Skip advanced options for now.", func() screen {
|
|
return newAdvancedMenuScreen(state, onBack, true)
|
|
}, func() screen {
|
|
if state.onboarding {
|
|
return newMenuScreen(state)
|
|
}
|
|
return onBack()
|
|
}, onBack)
|
|
}
|
|
|
|
func newAdvancedMenuScreen(state *wizardState, onBack func() screen, onboarding bool) screen {
|
|
items := []optionItem{
|
|
{title: formatAdvancedRecordingLabel(state.cfg), desc: "Sample rate, channels, device, and timeout.", value: "recording"},
|
|
}
|
|
if !onboarding {
|
|
items = append(items, optionItem{title: formatInjectionLabel(state.cfg), desc: "Backends for typing and clipboard fallback.", value: "injection"})
|
|
}
|
|
items = append(items, optionItem{title: formatAdvancedInjectionTimeoutLabel(state.cfg), desc: "Timeouts for ydotool, wtype, clipboard.", value: "timeouts"})
|
|
if onboarding {
|
|
items = append(items, optionItem{title: "Next", desc: "Continue without changing advanced settings.", value: "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 "injection":
|
|
return newInjectionScreen(state, func() screen { return newAdvancedMenuScreen(state, onBack, onboarding) })
|
|
case "timeouts":
|
|
return newInjectionTimeoutsScreen(state, func() screen { return newAdvancedMenuScreen(state, onBack, onboarding) })
|
|
case "next":
|
|
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", desc: "Write configuration to disk.", value: "save"},
|
|
{title: "Cancel", desc: "Go back without saving.", 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)", desc: "Local transcription with no API key.", value: "whisper-cpp"})
|
|
} else {
|
|
options = append(options, optionItem{title: "Whisper.cpp (local)", desc: "Install whisper-cli to enable local models.", 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", desc: "Balanced quality and cost.", value: "openai"})
|
|
case "groq":
|
|
options = append(options,
|
|
optionItem{title: "Groq Whisper", desc: "Fast transcription.", value: "groq-transcription"},
|
|
)
|
|
case "mistral":
|
|
options = append(options, optionItem{title: "Mistral Voxtral", desc: "Strong European language support.", value: "mistral-transcription"})
|
|
case "elevenlabs":
|
|
options = append(options, optionItem{title: "ElevenLabs Scribe", desc: "Best cloud quality.", value: "elevenlabs"})
|
|
case "deepgram":
|
|
options = append(options, optionItem{title: "Deepgram Nova", desc: "Great streaming performance.", value: "deepgram"})
|
|
}
|
|
}
|
|
}
|
|
|
|
configuredSet := make(map[string]bool)
|
|
for _, name := range configured {
|
|
configuredSet[name] = true
|
|
}
|
|
|
|
if !configuredSet["openai"] {
|
|
options = append(options, optionItem{title: "OpenAI Whisper", desc: "Balanced quality and cost.", value: "openai"})
|
|
}
|
|
if !configuredSet["groq"] {
|
|
options = append(options,
|
|
optionItem{title: "Groq Whisper", desc: "Fast transcription.", value: "groq-transcription"},
|
|
)
|
|
}
|
|
if !configuredSet["mistral"] {
|
|
options = append(options, optionItem{title: "Mistral Voxtral", desc: "Strong European language support.", value: "mistral-transcription"})
|
|
}
|
|
if !configuredSet["elevenlabs"] {
|
|
options = append(options, optionItem{title: "ElevenLabs Scribe", desc: "Best cloud quality.", value: "elevenlabs"})
|
|
}
|
|
if !configuredSet["deepgram"] {
|
|
options = append(options, optionItem{title: "Deepgram Nova", desc: "Great streaming performance.", 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", desc: "Configured. Balanced quality and cost.", value: "openai"})
|
|
case "groq":
|
|
options = append(options, optionItem{title: "Groq Llama", desc: "Configured. Very fast inference.", value: "groq"})
|
|
}
|
|
}
|
|
}
|
|
|
|
configuredSet := make(map[string]bool)
|
|
for _, name := range configured {
|
|
configuredSet[name] = true
|
|
}
|
|
if !configuredSet["openai"] {
|
|
options = append(options, optionItem{title: "OpenAI GPT", desc: "Requires API key. You'll be prompted.", value: "openai"})
|
|
}
|
|
if !configuredSet["groq"] {
|
|
options = append(options, optionItem{title: "Groq Llama", desc: "Requires API key. You'll be prompted.", value: "groq"})
|
|
}
|
|
|
|
return options
|
|
}
|
|
|
|
func formatProviderOption(cfg *config.Config, name string) string {
|
|
switch name {
|
|
case "openai":
|
|
return "OpenAI - Whisper + GPT"
|
|
case "groq":
|
|
return "Groq - Whisper + Llama"
|
|
case "mistral":
|
|
return "Mistral - Voxtral"
|
|
case "elevenlabs":
|
|
return "ElevenLabs - Scribe"
|
|
case "deepgram":
|
|
return "Deepgram - Nova"
|
|
default:
|
|
return name
|
|
}
|
|
}
|
|
|
|
func formatProviderOptionDesc(cfg *config.Config, name string) string {
|
|
status := "Not configured"
|
|
if pc, exists := cfg.Providers[name]; exists && pc.APIKey != "" {
|
|
status = "Configured"
|
|
}
|
|
|
|
recommendation := ""
|
|
switch name {
|
|
case "openai":
|
|
recommendation = "Recommended for balanced quality and cost."
|
|
case "groq":
|
|
recommendation = "Recommended for fastest turnaround."
|
|
case "mistral":
|
|
recommendation = "Recommended for European languages."
|
|
case "elevenlabs":
|
|
recommendation = "Recommended for best cloud quality."
|
|
case "deepgram":
|
|
recommendation = "Recommended for realtime streaming."
|
|
}
|
|
|
|
if recommendation == "" {
|
|
return status + "."
|
|
}
|
|
return status + ". " + recommendation
|
|
}
|
|
|
|
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 formatNotificationMessageTitle(def notify.MessageDef) string {
|
|
label := strings.ReplaceAll(def.ConfigKey, "_", " ")
|
|
return strings.Title(label)
|
|
}
|
|
|
|
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)
|
|
}
|