From 305f64a73d4e1666b527694aaa658e5bd310c280 Mon Sep 17 00:00:00 2001 From: leonardotrapani Date: Sun, 1 Feb 2026 22:04:42 +0100 Subject: [PATCH] feat: new readme --- AGENTS.md | 4 + README.md | 631 +++++-------------- cmd/hyprvoice/main.go | 50 +- docs/config.md | 11 + internal/config/config_test.go | 32 +- internal/config/defaults.go | 38 ++ internal/config/load.go | 17 +- internal/config/save.go | 2 +- internal/tui/configure_transcription_test.go | 28 +- internal/tui/flows.go | 275 ++++---- internal/tui/helpers.go | 53 +- internal/tui/screens.go | 20 +- internal/tui/styles.go | 19 +- internal/tui/types.go | 3 +- internal/tui/wizard.go | 9 +- packaging/hyprvoice.install | 10 +- 16 files changed, 517 insertions(+), 685 deletions(-) create mode 100644 internal/config/defaults.go diff --git a/AGENTS.md b/AGENTS.md index 3317e02..8a7240d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,10 @@ This repo is a Go CLI + daemon for voice-powered typing on Wayland/Hyprland. - IPC: unix socket at ~/.cache/hyprvoice/control.sock, single-character commands - Config: ~/.config/hyprvoice/config.toml (hot reloaded by daemon) +## Configuration +- First-time setup: hyprvoice onboarding (guided flow, no advanced settings) +- Full editor: hyprvoice configure (menu-based, includes advanced settings) + ## Docs - docs/structure.md: code map and entry points - docs/architecture.md: deeper architecture + adapters/interfaces diff --git a/README.md b/README.md index 1fb4829..58d07f7 100644 --- a/README.md +++ b/README.md @@ -1,441 +1,145 @@ -# Hyprvoice - Voice-Powered Typing for Hyprland / Wayland +# Hyprvoice - Voice-Powered Typing for Wayland/Hyprland -Press a toggle key, speak, and get instant text input. Built natively for Wayland/Hyprland - no X11 hacks or workarounds, just clean integration with modern Linux desktops. +26 voice models, cloud and local, built for Wayland dictation. -## Features +Press a toggle key, speak, and get instant text input. Built natively for Wayland/Hyprland with clean PipeWire capture and robust text injection. -- **Toggle workflow**: Press once to start recording, press again to stop and inject text -- **Interactive configuration**: User-friendly TUI wizard - no manual config file editing required -- **LLM post-processing**: Automatically cleans up transcriptions - removes stutters, fixes grammar, adds punctuation (enabled by default) -- **Wayland native**: Purpose-built for Wayland compositors - no legacy X11 dependencies or hacky workarounds -- **Real-time feedback**: Desktop notifications for recording states and transcription status -- **Multiple transcription backends**: OpenAI Whisper, Groq, Mistral Voxtral, ElevenLabs Scribe, and Deepgram Nova -- **Local transcription**: Offline transcription via whisper.cpp - no API keys, no cloud, complete privacy -- **Streaming transcription**: Real-time results with ElevenLabs, Deepgram, and OpenAI Realtime -- **57 language support**: Full multilingual support with language-model compatibility validation -- **Smart text injection**: Clipboard save/restore with direct typing fallback -- **Daemon architecture**: Lightweight control plane with efficient pipeline management +## Highlights -**Status:** Beta - core functionality complete and tested, ready for early adopters +- 26 speech-to-text models across cloud and local providers, including whisper.cpp. +- Streaming and batch transcription with 57-language support and model-language validation. +- Optional LLM post-processing plus keywords to preserve names and technical terms. +- Toggle workflow with optional status notifications and cancel support. +- Text injection via ydotool, wtype, and clipboard fallback with clipboard restore. +- Guided onboarding and a full configure menu with hot-reload. -## Installation +## Voice Providers and Models -### From AUR (Arch Linux) - Recommended +All supported speech-to-text providers and models: + +### OpenAI (cloud) + +- `whisper-1` (batch) +- `gpt-4o-transcribe` (batch) +- `gpt-4o-mini-transcribe` (batch) +- `gpt-4o-realtime-preview` (streaming) + +### Groq (cloud) + +- `whisper-large-v3` +- `whisper-large-v3-turbo` +- `distil-whisper-large-v3-en` (English only) + +### Mistral (cloud) + +- `voxtral-mini-latest` +- `voxtral-mini-2507` + +### ElevenLabs (cloud) + +- `scribe_v1` (batch) +- `scribe_v2` (batch) +- `scribe_v1-streaming` +- `scribe_v2-streaming` + +### whisper-cpp (local) + +- English-only: `tiny.en`, `base.en`, `small.en`, `medium.en` +- Multilingual: `tiny`, `base`, `small`, `medium`, `large-v3` + +### Deepgram (cloud) + +- `nova-3` +- `nova-3-general` +- `nova-2` +- `nova-2-general` + +Language coverage: 57 languages overall; Deepgram models cover a subset; English-only models are labeled above. + +## Installation (AUR) ```bash -# Install hyprvoice and all dependencies automatically yay -S hyprvoice-bin # or paru -S hyprvoice-bin ``` -The AUR package automatically installs all dependencies (`pipewire`, `wl-clipboard`, `wtype`, etc.) and sets up the systemd service. Follow the post-install instructions to complete setup. - -### Alternative: Download Binary - -For non-Arch users or testing: - -```bash -# Download and install binary -wget https://github.com/leonardotrapani/hyprvoice/releases/latest/download/hyprvoice-linux-x86_64 -mkdir -p ~/.local/bin -mv hyprvoice-linux-x86_64 ~/.local/bin/hyprvoice -chmod +x ~/.local/bin/hyprvoice - -# Add to PATH (add to ~/.bashrc or ~/.zshrc) -export PATH="$HOME/.local/bin:$PATH" - -# You'll need to manually install dependencies and create systemd service -# See Requirements section above -``` - -### Build from Source - -```bash -git clone https://github.com/leonardotrapani/hyprvoice.git -cd hyprvoice -go mod download -go build -o hyprvoice ./cmd/hyprvoice - -# Install locally -mkdir -p ~/.local/bin -cp hyprvoice ~/.local/bin/ -export PATH="$HOME/.local/bin:$PATH" -``` - -## Requirements - -- **Wayland desktop** (Hyprland, Niri, GNOME, KDE, etc.) -- **PipeWire audio system** with tools -- **API key for transcription**: OpenAI, Groq, Mistral, ElevenLabs, or Deepgram API key (check each provider's pricing), OR whisper.cpp for local transcription (no API key required) - -**System packages** (automatically installed with AUR package): - -- `pipewire`, `pipewire-pulse`, `pipewire-audio` - Audio capture -- `wl-clipboard` - Clipboard integration -- `wtype` - Text typing (Wayland) -- `ydotool` - Text typing (universal, recommended for Chromium apps) -- `libnotify` - Desktop notifications -- `systemd` - User service management - -For manual installation on other distros: - -```bash -# Ubuntu/Debian -sudo apt install pipewire-pulse pipewire-bin wl-clipboard wtype ydotool libnotify-bin - -# Fedora -sudo dnf install pipewire-utils wl-clipboard wtype ydotool libnotify - -# For ydotool, you also need to start the daemon: -systemctl --user enable --now ydotool -# Or add user to input group for uinput access: -sudo usermod -aG input $USER -``` +The package installs system dependencies and the systemd user service. +You'll still need an API key for a cloud provider, or whisper.cpp for local transcription. Onboarding will guide you through the choice. ## Quick Start -After installing via AUR: - -1. **Configure hyprvoice interactively:** +1. Run onboarding: ```bash -hyprvoice configure +hyprvoice onboarding ``` -This wizard will guide you through setting up your transcription provider, API key, audio preferences, and other settings. - -2. **Enable and start the service:** +2. Enable and start the service: ```bash systemctl --user enable --now hyprvoice.service ``` -3. **Add keybinding to your window manager:** +3. Add a keybinding (Hyprland example): ```bash -# For Hyprland, add to ~/.config/hypr/hyprland.conf bind = SUPER, R, exec, hyprvoice toggle ``` -4. **Test voice input:** +4. Test voice input: ```bash -# Check daemon status -hyprvoice status - -# Toggle recording (or use your keybind) hyprvoice toggle -# Speak something... -hyprvoice toggle # Stop and transcribe +``` + +Run `hyprvoice configure` anytime for advanced settings. + +## Commands + +### Core CLI + +```bash +hyprvoice onboarding +hyprvoice configure +hyprvoice serve +hyprvoice toggle +hyprvoice cancel +hyprvoice status +hyprvoice version +hyprvoice stop +``` + +### Model management (whisper-cpp) + +```bash +hyprvoice model list +hyprvoice model list --provider whisper-cpp +hyprvoice model download base.en +hyprvoice model remove base.en +``` + +### Service management + +```bash +systemctl --user status hyprvoice.service +systemctl --user restart hyprvoice.service +journalctl --user -u hyprvoice.service -f ``` ## Configuration -The recommended way to configure hyprvoice is through the interactive wizard: +Configuration lives in `~/.config/hyprvoice/config.toml` and hot-reloads automatically. -```bash -hyprvoice configure -``` +- First-time setup: `hyprvoice onboarding` +- Full TUI editor: `hyprvoice configure` -The wizard guides you through all settings with a user-friendly interface: +## Docs -- **Providers** - API keys for OpenAI, Groq, Mistral, ElevenLabs, Deepgram -- **Transcription** - Speech-to-text provider, model, and language selection (cloud or local) -- **LLM** - Post-processing to clean up transcriptions (enabled by default) -- **Keywords** - Domain-specific terms for better accuracy -- **Injection** - How text is typed (ydotool, wtype, clipboard) -- **Notifications** - Desktop notification preferences -- **Advanced Settings** - Recording parameters, timeouts - -Configuration is stored in `~/.config/hyprvoice/config.toml`. Changes are applied immediately without restarting the daemon. - -For manual configuration and detailed options, see [docs/config.md](docs/config.md). - -## Quick Reference - -### Common Commands - -```bash -# Interactive configuration wizard -hyprvoice configure - -# Start the daemon -hyprvoice serve - -# Toggle recording on/off -hyprvoice toggle - -# Cancel current operation -hyprvoice cancel - -# Check current status -hyprvoice status - -# Get protocol version -hyprvoice version - -# Stop the daemon (if not using systemd service) -hyprvoice stop -``` - -### Model Management (Local Transcription) - -```bash -# List all available models -hyprvoice model list - -# List only transcription models -hyprvoice model list --type transcription - -# List models for a specific provider -hyprvoice model list --provider whisper-cpp - -# Download a local model -hyprvoice model download base.en - -# Remove a downloaded model -hyprvoice model remove base.en -``` - -### Keybinding Pattern - -Most setups use this toggle pattern in window manager config: - -```bash -bind = SUPER, R, exec, hyprvoice toggle -bind = SUPER SHIFT, R, exec, hyprvoice cancel # Optional: cancel current operation -``` - -## Keyboard Shortcuts Setup - -### Hyprland - -Add to your `~/.config/hypr/hyprland.conf`: - -```bash -# Hyprvoice - Voice to Text (toggle recording) -bind = SUPER, R, exec, hyprvoice toggle - -# Optional: Cancel current operation -bind = SUPER SHIFT, C, exec, hyprvoice cancel - -# Optional: Status check -bind = SUPER SHIFT, R, exec, hyprvoice status && notify-send "Hyprvoice" "$(hyprvoice status)" -``` - -## Usage Examples - -### Basic Toggle Workflow - -1. **Press keybind** → Recording starts (notification appears) -2. **Speak your text** → Audio captured in real-time -3. **Press keybind again** → Recording stops, transcription begins -4. **Text appears** → Injected at cursor position or clipboard - -**Cancel anytime:** Press your cancel keybind (e.g., `SUPER+SHIFT+C`) to abort the current operation and return to idle. - -### CLI Usage - -```bash -# Start daemon manually (if not using systemd service) -hyprvoice serve - -# In another terminal: toggle recording -hyprvoice toggle -# ... speak ... -hyprvoice toggle - -# Check what's happening -hyprvoice status -``` - -## Local Transcription - -For complete offline privacy, use whisper.cpp for local transcription - no API keys, no cloud, no data leaves your machine. - -### Prerequisites - -1. **Install whisper.cpp**: Build from source or install via package manager - - ```bash - # Arch Linux - yay -S whisper.cpp - - # Build from source (recommended for CUDA/Metal support) - git clone https://github.com/ggerganov/whisper.cpp - cd whisper.cpp && make - sudo cp main /usr/local/bin/whisper-cli - ``` - -2. **Download a model**: - - ```bash - # List available models - hyprvoice model list --provider whisper-cpp - - # Download recommended model (142MB, English-only, fast) - hyprvoice model download base.en - - # Or download multilingual model (142MB, 57 languages) - hyprvoice model download base - ``` - -### Available Models - -| Model | Size | Languages | Speed | Accuracy | -| ---------- | ----- | ----------- | -------- | -------- | -| tiny.en | 75MB | English | Fastest | Good | -| base.en | 142MB | English | Fast | Better | -| small.en | 466MB | English | Medium | Great | -| medium.en | 1.5GB | English | Slow | Excellent| -| tiny | 75MB | 57 langs | Fastest | Good | -| base | 142MB | 57 langs | Fast | Better | -| small | 466MB | 57 langs | Medium | Great | -| medium | 1.5GB | 57 langs | Slow | Excellent| -| large-v3 | 3GB | 57 langs | Slowest | Best | - -**Recommendation**: Start with `base.en` for English or `base` for multilingual. Models ending in `.en` are English-only but slightly faster. - -Run `hyprvoice configure` to set up local transcription, or see [docs/config.md](docs/config.md) for manual configuration. - -## Streaming Transcription - -For real-time transcription results as you speak, use streaming providers. Text appears progressively instead of waiting for the entire recording to finish. - -### Streaming Providers - -| Provider | Models | Latency | Languages | -| ---------- | -------------------------- | ---------- | --------- | -| ElevenLabs | scribe_v1-streaming, scribe_v2-streaming | ~150ms | 57 langs | -| Deepgram | nova-3, nova-2 | ~100ms | 40+ langs | -| OpenAI | gpt-4o-realtime-preview | ~200ms | 57 langs | - -Streaming models show partial results while recording. Final text is accumulated and injected when you toggle off. - -Run `hyprvoice configure` to set up streaming, or see [docs/config.md](docs/config.md) for manual configuration. - -### Service Management - -The systemd user service is automatically installed with the AUR package: - -```bash -# Check service status -systemctl --user status hyprvoice.service - -# Start/stop service -systemctl --user start hyprvoice.service -systemctl --user stop hyprvoice.service - -# Enable/disable autostart -systemctl --user enable hyprvoice.service -systemctl --user disable hyprvoice.service - -# View logs -journalctl --user -u hyprvoice.service -f -``` - -### File Locations - -- **Socket**: `~/.cache/hyprvoice/control.sock` - IPC communication -- **PID file**: `~/.cache/hyprvoice/hyprvoice.pid` - Process tracking -- **Config**: `~/.config/hyprvoice/config.toml` - User settings -- **Models**: `~/.local/share/hyprvoice/models/whisper/` - Downloaded whisper models - -## Development Status - -| Component | Status | Notes | -| ------------------------ | ------ | ----------------------------------------------------- | -| Core daemon & IPC | ✅ | Unix socket control plane | -| Recording workflow | ✅ | Toggle recording via PipeWire | -| Audio capture | ✅ | Efficient PipeWire integration | -| Desktop notifications | ✅ | Status feedback via notify-send | -| OpenAI transcription | ✅ | HTTP API + Realtime streaming | -| Groq transcription | ✅ | Fast Whisper API with transcription and translation | -| Mistral transcription | ✅ | Voxtral API for European languages | -| ElevenLabs transcription | ✅ | Scribe batch + streaming (90+ languages) | -| Deepgram transcription | ✅ | Nova-3 streaming (40+ languages) | -| Local transcription | ✅ | whisper.cpp with model download management | -| Streaming support | ✅ | Real-time results with ElevenLabs, Deepgram, OpenAI | -| Model management | ✅ | `hyprvoice model list/download/remove` CLI | -| Language validation | ✅ | Model-language compatibility checking | -| LLM post-processing | ✅ | OpenAI/Groq text cleanup (enabled by default) | -| Text injection | ✅ | Clipboard + wtype/ydotool with fallback | -| Configuration system | ✅ | TOML-based user settings with hot-reload | -| Interactive TUI setup | ✅ | `hyprvoice configure` wizard with section editing | -| Unit test coverage | ✅ | Comprehensive test suite (100% pass) | -| CI/CD Pipeline | ✅ | Automated builds and releases via GitHub Actions | -| Installation (AUR etc) | ✅ | AUR package with automated dependency installation | - -**Legend**: ✅ Complete · ⏳ Planned - -## Architecture Overview - -Hyprvoice uses a **daemon + pipeline** architecture for efficient resource management: - -- **Control Daemon**: Lightweight IPC server managing lifecycle -- **Pipeline**: Stateful audio processing (recording → transcribing → processing → injecting) -- **State Machine**: `idle → recording → transcribing → processing → injecting → idle` - -### System Architecture - -```mermaid -flowchart LR - subgraph Client - CLI["CLI/Tool"] - end - subgraph Daemon - D["Control Daemon (lifecycle + IPC)"] - end - subgraph Pipeline - A["Audio Capture"] - T["Transcribing"] - I["Injecting (wtype + clipboard)"] - end - N["notify-send/log"] - - CLI -- unix socket --> D - D -- start/stop --> A - A -- frames --> T - T -- status --> D - D -- events --> N - D -- inject action --> T - T --> I - I -->|done| D -``` - -```mermaid -stateDiagram-v2 - [*] --> idle - idle --> recording: toggle - recording --> transcribing: first_frame - transcribing --> processing: llm_enabled - transcribing --> injecting: llm_disabled - processing --> injecting: inject_action - injecting --> idle: done - recording --> idle: abort - injecting --> idle: abort -``` - -### How It Works - -1. **Toggle recording** → Pipeline starts, audio capture begins -2. **Audio streaming** → PipeWire frames buffered for transcription -3. **Toggle stop** → Recording ends, transcription starts -4. **LLM processing** → Text cleaned up (if enabled, which is the default) -5. **Text injection** → Result typed or copied to clipboard -6. **Return to idle** → Pipeline cleaned up, ready for next session - -### Data Flow - -1. `toggle` (daemon) → create pipeline → recording -2. First frame arrives → transcribing (daemon may notify `Transcribing` later) -3. Audio frames → audio buffer (collect all audio during session) -4. Second `toggle` during transcribing → transcribe collected audio -5. If LLM enabled → processing → clean up text with LLM -6. injecting → type or paste text -7. Complete → idle; pipeline stops; daemon clears reference -8. Notifications at key transitions +- `docs/config.md` - configuration reference and examples +- `docs/providers.md` - provider and model details +- `docs/architecture.md` - architecture and adapter overview +- `docs/structure.md` - code map and entry points ## Troubleshooting @@ -551,90 +255,73 @@ hyprvoice toggle hyprvoice status ``` -## Development +## Architecture Overview -### Building from Source +Hyprvoice uses a **daemon + pipeline** architecture for efficient resource management: -```bash -git clone https://github.com/leonardotrapani/hyprvoice.git -cd hyprvoice -go mod download -go build -o hyprvoice ./cmd/hyprvoice +- **Control Daemon**: Lightweight IPC server managing lifecycle +- **Pipeline**: Stateful audio processing (recording → transcribing → processing → injecting) +- **State Machine**: `idle → recording → transcribing → processing → injecting → idle` -# Install locally -mkdir -p ~/.local/bin -cp hyprvoice ~/.local/bin/ -export PATH="$HOME/.local/bin:$PATH" +### System Architecture + +```mermaid +flowchart LR + subgraph Client + CLI["CLI/Tool"] + end + subgraph Daemon + D["Control Daemon (lifecycle + IPC)"] + end + subgraph Pipeline + A["Audio Capture"] + T["Transcribing"] + I["Injecting (wtype + clipboard)"] + end + N["notify-send/log"] + + CLI -- unix socket --> D + D -- start/stop --> A + A -- frames --> T + T -- status --> D + D -- events --> N + D -- inject action --> T + T --> I + I -->|done| D ``` -## For Maintainers - -### Publishing to AUR - -See [`packaging/RELEASE.md`](packaging/RELEASE.md) for complete release process including AUR deployment. - -Quick start for AUR: - -```bash -# After creating your first GitHub release -cd packaging/ -./setup-aur.sh # One-time AUR repository setup +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> recording: toggle + recording --> transcribing: first_frame + transcribing --> processing: llm_enabled + transcribing --> injecting: llm_disabled + processing --> injecting: inject_action + injecting --> idle: done + recording --> idle: abort + injecting --> idle: abort ``` -### Project Structure +### How It Works -``` -hyprvoice/ -├── cmd/hyprvoice/ # CLI application entry point -├── internal/ -│ ├── bus/ # IPC (Unix socket) + PID management -│ ├── config/ # Configuration loading and validation -│ ├── daemon/ # Control daemon (lifecycle management) -│ ├── deps/ # Dependency checking (whisper-cli, ffmpeg) -│ ├── injection/ # Text injection (clipboard + wtype + ydotool) -│ ├── language/ # Language codes and provider-specific mappings -│ ├── llm/ # LLM post-processing adapters (OpenAI, Groq) -│ ├── models/whisper/ # Whisper model info and download management -│ ├── notify/ # Desktop notification integration -│ ├── pipeline/ # Audio processing pipeline + state machine -│ ├── provider/ # Provider registry with Model metadata -│ ├── recording/ # PipeWire audio capture -│ ├── transcriber/ # Batch and streaming adapters (OpenAI, Groq, Mistral, ElevenLabs, Deepgram, whisper-cpp) -│ └── tui/ # Interactive configuration wizard -├── go.mod # Go module definition -└── README.md -``` +1. **Toggle recording** → Pipeline starts, audio capture begins +2. **Audio streaming** → PipeWire frames buffered for transcription +3. **Toggle stop** → Recording ends, transcription starts +4. **LLM processing** → Text cleaned up (if enabled) +5. **Text injection** → Result typed or copied to clipboard +6. **Return to idle** → Pipeline cleaned up, ready for next session -### Development Workflow +### Data Flow -```bash -# Terminal 1: Run daemon with logs -go run ./cmd/hyprvoice serve - -# Terminal 2: Test commands -go run ./cmd/hyprvoice toggle -go run ./cmd/hyprvoice status -go run ./cmd/hyprvoice stop -``` - -### IPC Protocol - -Simple single-character commands over Unix socket: - -- `t` - Toggle recording on/off -- `c` - Cancel current operation -- `s` - Get current status -- `v` - Get protocol version -- `q` - Quit daemon gracefully - -## Contributing - -Contributions welcome! Please: - -- Follow existing code conventions and patterns -- Add tests for new functionality when available -- Update documentation for user-facing changes -- Test on Hyprland/Wayland before submitting PRs +1. `toggle` (daemon) → create pipeline → recording +2. First frame arrives → transcribing (daemon may notify `Transcribing` later) +3. Audio frames → audio buffer (collect all audio during session) +4. Second `toggle` during transcribing → transcribe collected audio +5. If LLM enabled → processing → clean up text with LLM +6. injecting → type or paste text +7. Complete → idle; pipeline stops; daemon clears reference +8. Notifications at key transitions ## License diff --git a/cmd/hyprvoice/main.go b/cmd/hyprvoice/main.go index 4ff9116..8ffdad0 100644 --- a/cmd/hyprvoice/main.go +++ b/cmd/hyprvoice/main.go @@ -2,8 +2,10 @@ package main import ( "context" + "errors" "fmt" "os/exec" + "path/filepath" "sort" "strings" @@ -33,6 +35,7 @@ func init() { statusCmd(), versionCmd(), stopCmd(), + onboardingCmd(), configureCmd(), modelCmd(), ) @@ -128,8 +131,6 @@ func cancelCmd() *cobra.Command { } func configureCmd() *cobra.Command { - var onboarding bool - cmd := &cobra.Command{ Use: "configure", Short: "Interactive configuration setup", @@ -138,22 +139,46 @@ This will guide you through setting up: - Provider API keys (OpenAI, Groq, Mistral, ElevenLabs) - Transcription settings - LLM post-processing -- Text injection and notification preferences`, + - Text injection and notification preferences + +For first-time setup, run 'hyprvoice onboarding'.`, RunE: func(cmd *cobra.Command, args []string) error { - return runConfigure(onboarding) + return runConfigure(false) }, } - cmd.Flags().BoolVar(&onboarding, "onboarding", false, "Run the guided onboarding wizard") - return cmd } +func onboardingCmd() *cobra.Command { + return &cobra.Command{ + Use: "onboarding", + Short: "Guided first-time setup", + Long: `Guided onboarding wizard for hyprvoice. +This will walk you through the full setup flow (excluding advanced options).`, + RunE: func(cmd *cobra.Command, args []string) error { + return runConfigure(true) + }, + } +} + func runConfigure(onboarding bool) error { - // Load existing config or create default - cfg, err := config.Load() - if err != nil { - return fmt.Errorf("failed to load config: %w", err) + var cfg *config.Config + var err error + if onboarding { + cfg, err = config.Load() + if err != nil { + if errors.Is(err, config.ErrConfigNotFound) { + cfg = config.DefaultConfig() + } else { + return fmt.Errorf("failed to load config: %w", err) + } + } + } else { + cfg, err = config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } } // Run TUI wizard @@ -224,6 +249,11 @@ func showNextSteps(cfg *config.Config, onboarding bool) { fmt.Println() configPath, _ := config.GetConfigPath() + if onboarding { + configDir := filepath.Dir(configPath) + fmt.Printf("run hyprvoice configure to configure more, or check %s\n", configDir) + return + } fmt.Printf("Config file location: %s\n", configPath) } diff --git a/docs/config.md b/docs/config.md index 4d4123f..329fe64 100644 --- a/docs/config.md +++ b/docs/config.md @@ -2,12 +2,23 @@ This document covers manual configuration of hyprvoice via the `config.toml` file. For most users, the interactive wizard is recommended: +```bash +hyprvoice onboarding +``` + +To adjust settings later: + ```bash hyprvoice configure ``` Configuration is stored in `~/.config/hyprvoice/config.toml` and changes are applied immediately without restarting the daemon. +## Onboarding vs Configure + +- `hyprvoice onboarding`: guided first-time setup for provider keys, voice model, language/streaming, LLM post-processing, keywords, and notifications. Advanced settings stay at defaults. +- `hyprvoice configure`: full TUI menu for all sections, including advanced recording, injection backends, timeouts, and notification messages. + ## Table of Contents - [Unified Provider System](#unified-provider-system) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c5cba75..b8e66dd 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "errors" "os" "path/filepath" "runtime" @@ -261,41 +262,34 @@ func TestConfig_Validate(t *testing.T) { } func TestConfig_Load(t *testing.T) { - // Test that Load creates default config when none exists - t.Run("creates default config when none exists", func(t *testing.T) { + // Test that Load errors when no config exists + t.Run("errors when config missing", func(t *testing.T) { tempDir := t.TempDir() originalConfigDir := os.Getenv("XDG_CONFIG_HOME") - originalAPIKey := os.Getenv("OPENAI_API_KEY") os.Setenv("XDG_CONFIG_HOME", tempDir) - os.Setenv("OPENAI_API_KEY", "test-api-key") // Set test API key for validation defer func() { if originalConfigDir == "" { os.Unsetenv("XDG_CONFIG_HOME") } else { os.Setenv("XDG_CONFIG_HOME", originalConfigDir) } - if originalAPIKey == "" { - os.Unsetenv("OPENAI_API_KEY") - } else { - os.Setenv("OPENAI_API_KEY", originalAPIKey) - } }() - config, err := Load() - if err != nil { - t.Errorf("Load() error = %v", err) + _, err := Load() + if err == nil { + t.Errorf("Load() expected error when config is missing") return } - - // Verify the loaded config is valid - if err := config.Validate(); err != nil { - t.Errorf("Loaded config is invalid: %v", err) + if !errors.Is(err, ErrConfigNotFound) { + t.Errorf("Load() error = %v, expected ErrConfigNotFound", err) + } + if !strings.Contains(err.Error(), "hyprvoice onboarding") { + t.Errorf("Load() error should mention onboarding: %v", err) } - // Verify config file was created configPath := filepath.Join(tempDir, "hyprvoice", "config.toml") - if _, err := os.Stat(configPath); os.IsNotExist(err) { - t.Errorf("Load() did not create config file") + if _, statErr := os.Stat(configPath); !os.IsNotExist(statErr) { + t.Errorf("Load() should not create config file when missing") } }) diff --git a/internal/config/defaults.go b/internal/config/defaults.go new file mode 100644 index 0000000..bc6410f --- /dev/null +++ b/internal/config/defaults.go @@ -0,0 +1,38 @@ +package config + +import "time" + +// DefaultConfig returns the initial configuration used for onboarding. +func DefaultConfig() *Config { + return &Config{ + Recording: RecordingConfig{ + SampleRate: 16000, + Channels: 1, + Format: "s16", + BufferSize: 8192, + Device: "", + ChannelBufferSize: 30, + Timeout: 5 * time.Minute, + }, + Transcription: TranscriptionConfig{ + Language: "", + Streaming: false, + Threads: 0, + }, + Injection: InjectionConfig{ + Backends: []string{"ydotool", "wtype", "clipboard"}, + YdotoolTimeout: 5 * time.Second, + WtypeTimeout: 5 * time.Second, + ClipboardTimeout: 3 * time.Second, + }, + Notifications: NotificationsConfig{ + Enabled: false, + Type: "", + }, + Providers: make(map[string]ProviderConfig), + Keywords: nil, + LLM: LLMConfig{ + Enabled: false, + }, + } +} diff --git a/internal/config/load.go b/internal/config/load.go index 0174839..8adbd5b 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "log" "os" @@ -11,6 +12,8 @@ import ( "github.com/BurntSushi/toml" ) +var ErrConfigNotFound = errors.New("config not found") + func GetConfigPath() (string, error) { configDir, err := os.UserConfigDir() if err != nil { @@ -47,12 +50,9 @@ func Load() (*Config, error) { } if _, err := os.Stat(configPath); os.IsNotExist(err) { - log.Printf("Config: no config file found at %s, creating with defaults", configPath) - if err := SaveDefaultConfig(); err != nil { - return nil, fmt.Errorf("failed to create default config: %w", err) - } - log.Printf("Config: default configuration created successfully") - return Load() + return nil, fmt.Errorf("%w: run hyprvoice onboarding", ErrConfigNotFound) + } else if err != nil { + return nil, fmt.Errorf("failed to stat config file %s: %w", configPath, err) } log.Printf("Config: loading configuration from %s", configPath) @@ -76,6 +76,11 @@ func Load() (*Config, error) { config.Providers = make(map[string]ProviderConfig) } + if config.Transcription.Provider == "groq-translation" { + log.Printf("Config: deprecated transcription.provider 'groq-translation' detected - using 'groq-transcription' instead") + config.Transcription.Provider = "groq-transcription" + } + config.applyLLMDefaults() config.applyThreadsDefault() diff --git a/internal/config/save.go b/internal/config/save.go index 83b899a..45de1a1 100644 --- a/internal/config/save.go +++ b/internal/config/save.go @@ -23,7 +23,7 @@ func Save(cfg *Config) error { // Header sb.WriteString(`# Hyprvoice Configuration -# Generated by hyprvoice configure +# Generated by hyprvoice onboarding or configure # Changes are applied immediately without daemon restart. `) diff --git a/internal/tui/configure_transcription_test.go b/internal/tui/configure_transcription_test.go index 01f42f5..12307c3 100644 --- a/internal/tui/configure_transcription_test.go +++ b/internal/tui/configure_transcription_test.go @@ -24,14 +24,14 @@ func TestGetTranscriptionModelOptions_ShowsCapabilities(t *testing.T) { } if model.SupportsStreaming && !model.SupportsBatch { - // streaming-only should have [streaming] tag - if !strings.Contains(opt.Label, "[streaming]") { - t.Errorf("streaming-only model %s should have [streaming] tag in label: %s", opt.ID, opt.Label) + // streaming-only should mention streaming + if !strings.Contains(opt.Desc, "streaming") { + t.Errorf("streaming-only model %s should mention streaming in desc: %s", opt.ID, opt.Desc) } } else if model.SupportsBothModes() { - // both modes should have [batch+streaming] tag - 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) + // both modes should mention batch+streaming + if !strings.Contains(opt.Desc, "batch+streaming") { + t.Errorf("both-modes model %s should mention batch+streaming in desc: %s", opt.ID, opt.Desc) } } // batch-only models don't need a tag @@ -44,7 +44,7 @@ func TestGetTranscriptionModelOptions_NoHeadersAnymore(t *testing.T) { for _, opt := range options { if opt.ID == "" { - t.Errorf("should not have headers anymore, got: %s", opt.Label) + t.Errorf("should not have headers anymore, got empty id") } } } @@ -57,11 +57,11 @@ func TestGetTranscriptionModelOptions_OpenAI_ShowsCapabilities(t *testing.T) { t.Errorf("expected 3 options for openai, got %d", len(options)) } - // gpt-4o-transcribe and gpt-4o-mini-transcribe should have [batch+streaming] + // gpt-4o-transcribe and gpt-4o-mini-transcribe should mention batch+streaming for _, opt := range options { 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) + if !strings.Contains(opt.Desc, "batch+streaming") { + t.Errorf("gpt-4o model %s should mention batch+streaming: %s", opt.ID, opt.Desc) } } } @@ -76,8 +76,8 @@ func TestGetTranscriptionModelOptions_Deepgram_ShowsBothModes(t *testing.T) { } for _, opt := range options { - if !strings.Contains(opt.Label, "[batch+streaming]") { - t.Errorf("deepgram model %s should have [batch+streaming] tag: %s", opt.ID, opt.Label) + if !strings.Contains(opt.Desc, "batch+streaming") { + t.Errorf("deepgram model %s should mention batch+streaming: %s", opt.ID, opt.Desc) } } } @@ -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.Label, "[streaming]") || strings.Contains(opt.Label, "[batch]") { - t.Errorf("batch-only model should not have mode tags: %s", opt.Label) + if strings.Contains(opt.Desc, "streaming") { + t.Errorf("batch-only model should not mention streaming: %s", opt.Desc) } } } diff --git a/internal/tui/flows.go b/internal/tui/flows.go index e328603..46e6e0f 100644 --- a/internal/tui/flows.go +++ b/internal/tui/flows.go @@ -21,7 +21,6 @@ const ( menuVoiceModel = "voice_model" menuLLM = "llm" menuKeywords = "keywords" - menuInjection = "injection" menuNotifications = "notifications" menuAdvanced = "advanced" menuSave = "save" @@ -29,12 +28,10 @@ const ( ) 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) + desc := append([]string{}, LogoLines()...) + desc = append(desc, "", "Voice-powered typing for Wayland/Hyprland.", "Let's set up your configuration.") + s := newInfoScreen(state, "Hyprvoice Onboarding", desc, func() screen { + return onboardingVoiceProviderScreen(state) }, func() screen { state.cancelled = true state.result = &ConfigureResult{Cancelled: true} @@ -44,17 +41,9 @@ func newWelcomeScreen(state *wizardState) screen { 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 newWelcomeScreen(state) }, func() screen { return onboardingLLMScreen(state) }, ) } @@ -66,20 +55,26 @@ func onboardingLLMScreen(state *wizardState) screen { ) } +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: 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}, + {title: formatProvidersLabel(state.cfg), desc: "Manage API keys for cloud providers.", value: menuProviders}, + {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: formatNotificationsLabel(state.cfg), desc: "Notification type and message text.", value: menuNotifications}, + {title: "Advanced Settings", desc: "Recording, injection, and timeout settings.", value: menuAdvanced}, + {title: "Save & Exit", desc: "Write config changes to disk.", value: menuSave}, + {title: "Discard & Exit", desc: "Exit without saving changes.", value: menuDiscard}, } - desc := []string{"Select a section to update."} + 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: @@ -90,8 +85,6 @@ func newMenuScreen(state *wizardState) screen { 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: @@ -117,18 +110,24 @@ func newMenuScreen(state *wizardState) 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}) + 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, value: "back"}) + items = append(items, optionItem{title: exitLabel, desc: exitDesc, 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.", + "Recommended: for cloud quality, ElevenLabs is the top pick.", "Tip: press / to filter.", } @@ -160,9 +159,9 @@ func newProviderKeyFlow(state *wizardState, providerName string, onContinue func 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 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() }) + }, func() screen { return onContinue() }, onCancel) } return newAPIKeyInputScreen(state, providerName, onContinue, onCancel) } @@ -229,7 +228,7 @@ func newVoiceProviderScreen(state *wizardState, onBack func() screen, onNext fun selectedProvider := item.value providerName := selectedProvider switch selectedProvider { - case "groq-transcription", "groq-translation": + case "groq-transcription": providerName = "groq" case "mistral-transcription": providerName = "mistral" @@ -262,7 +261,7 @@ func newVoiceModelScreen(state *wizardState, providerName string, onBack func() items := make([]optionItem, 0, len(options)) for _, opt := range options { - items = append(items, optionItem{title: opt.Label, value: opt.ID}) + items = append(items, optionItem{title: opt.Title, desc: opt.Desc, value: opt.ID}) } desc := []string{ @@ -280,11 +279,11 @@ func newVoiceModelScreen(state *wizardState, providerName string, onBack func() return nil } confirmDesc := []string{fmt.Sprintf("Download %s (%s)?", modelInfo.Name, modelInfo.Size)} - return newConfirmScreen(state, "Download Model", confirmDesc, "Download", "Cancel", func() screen { + 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) }, nil) } return applyVoiceModelSelection(state, providerName, item.value, onBack, onNext) }, func() screen { return onBack() }) @@ -299,6 +298,7 @@ func newVoiceModelScreen(state *wizardState, providerName string, onBack func() 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) @@ -317,20 +317,24 @@ func applyVoiceModelSelection(state *wizardState, providerName, modelID string, } else { state.cfg.Transcription.Language = "" } - return applyStreamingSelection(state, model, onNext) + return applyStreamingSelection(state, model, backToModels, onNext) } return newLanguageScreen(state, model, func() screen { return newVoiceModelScreen(state, providerName, onBack, onNext) }, func() screen { - return applyStreamingSelection(state, model, onNext) + 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 (recommended)", value: ""}} + items := []optionItem{{title: "Auto-detect", desc: "Recommended. Let the model detect language.", value: ""}} for _, code := range model.SupportedLanguages { - items = append(items, optionItem{title: code, value: code}) + 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 { @@ -345,16 +349,16 @@ func newLanguageScreen(state *wizardState, model *provider.Model, onBack func() return screen } -func applyStreamingSelection(state *wizardState, model *provider.Model, next func() screen) 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."} - return newConfirmScreen(state, "Enable Streaming Mode?", desc, "Yes, streaming", "No, batch", func() screen { + return newConfirmScreen(state, "Enable Streaming Mode?", desc, "Yes, streaming", "Lower latency, higher resource use.", "No, batch", "Wait for full transcription.", 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 @@ -371,12 +375,12 @@ func newLLMEnableScreen(state *wizardState, onBack func() screen, onNext func() } else { desc = []string{"Currently disabled.", desc[0]} } - return newConfirmScreen(state, "Enable LLM Post-Processing?", desc, "Yes (recommended)", "No", func() screen { + return newConfirmScreen(state, "Enable LLM Post-Processing?", desc, "Yes (recommended)", "Clean up grammar and punctuation.", "No", "Keep raw transcription text.", 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 { @@ -414,7 +418,13 @@ func newLLMModelScreen(state *wizardState, providerName string, onBack func() sc 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 := 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 { @@ -441,10 +451,10 @@ func newPostProcessingScreen(state *wizardState, onBack func() screen, onNext fu } 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}, + {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."} @@ -483,7 +493,8 @@ func newCustomPromptConfirmScreen(state *wizardState, onBack func() screen, onNe } else { desc = append([]string{"Current prompt: none."}, desc...) } - return newConfirmScreen(state, "Add Custom Prompt?", desc, "Yes", "No", func() screen { + 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") @@ -499,7 +510,7 @@ func newCustomPromptConfirmScreen(state *wizardState, onBack func() screen, onNe state.cfg.LLM.CustomPrompt.Enabled = false state.cfg.LLM.Enabled = true return onNext() - }) + }, prev) } func newKeywordsScreen(state *wizardState, onBack func() screen) screen { @@ -526,7 +537,7 @@ func newKeywordsScreen(state *wizardState, onBack func() screen) screen { state.cfg.Keywords = keywords } if state.onboarding { - return newInjectionScreen(state, func() screen { return newKeywordsScreen(state, onBack) }) + return newNotificationsScreen(state, func() screen { return newKeywordsScreen(state, onBack) }) } return onBack() }, onBack) @@ -543,9 +554,9 @@ func newInjectionScreen(state *wizardState, onBack func() screen) screen { } 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"]}, + {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 { @@ -556,9 +567,6 @@ func newInjectionScreen(state *wizardState, onBack func() screen) screen { } } 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" @@ -572,16 +580,20 @@ func newNotificationsScreen(state *wizardState, onBack func() screen) screen { } else { desc = append([]string{"Currently disabled."}, desc...) } - return newConfirmScreen(state, "Enable Desktop Notifications?", desc, "Yes", "No", func() screen { + 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 = "" + } return newNotificationTypeScreen(state, onBack) }, func() screen { state.cfg.Notifications.Enabled = false if state.onboarding { - return newAdvancedPromptScreen(state, onBack) + state.cfg.Notifications.Type = "none" + return onboardingSummaryScreen(state, onBack) } return onBack() - }) + }, onBack) } func newNotificationTypeScreen(state *wizardState, onBack func() screen) screen { @@ -589,9 +601,9 @@ func newNotificationTypeScreen(state *wizardState, onBack func() screen) screen 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"}, + {title: "Reccomended: 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 { @@ -605,14 +617,15 @@ func newNotificationTypeScreen(state *wizardState, onBack func() screen) 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 { + 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 newAdvancedPromptScreen(state, onBack) + return onboardingSummaryScreen(state, onBack) } return onBack() - }) + }, prev) } func newNotificationMessagesScreen(state *wizardState, onBack func() screen) screen { @@ -626,20 +639,22 @@ func newNotificationMessagesScreen(state *wizardState, onBack func() screen) scr if len(display) > 40 { display = display[:40] + "..." } - label := fmt.Sprintf("%s: \"%s\"", def.ConfigKey, display) - items = append(items, optionItem{title: label, value: def.ConfigKey}) + 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: "Back", value: "back"}) + items = append(items, optionItem{title: "Back", desc: "Return without editing.", 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" { - if state.onboarding { - return newAdvancedPromptScreen(state, onBack) - } - return onBack() + return backFn() } return newNotificationMessageEditScreen(state, item.value, func() screen { return newNotificationMessagesScreen(state, onBack) }) - }, func() screen { return onBack() }) + }, func() screen { return backFn() }) screen.footer = "enter select • esc back • / filter" return screen } @@ -675,34 +690,38 @@ func newNotificationMessageEditScreen(state *wizardState, configKey string, onBa } 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 { + 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), value: "recording"}, - {title: formatAdvancedInjectionTimeoutLabel(state.cfg), value: "timeouts"}, - {title: "Back", value: "back"}, + {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[len(items)-1].title = "Next" + 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 "back": + case "next": if onboarding { return newMenuScreen(state) } @@ -809,8 +828,8 @@ func newInjectionTimeoutsScreen(state *wizardState, onBack func() screen) screen func newSummaryScreen(state *wizardState, onBack func() screen) screen { summary := buildSummaryLines(state.cfg) items := []optionItem{ - {title: "Save", value: "save"}, - {title: "Cancel", value: "cancel"}, + {title: "Save", desc: "Write configuration to disk.", value: "save"}, + {title: "Cancel", desc: "Go back without saving.", value: "cancel"}, } desc := []string{} desc = append(desc, summary...) @@ -831,9 +850,9 @@ func buildVoiceProviderOptions(cfg *config.Config) []optionItem { whisperStatus := deps.CheckWhisperCli() if whisperStatus.Installed { - options = append(options, optionItem{title: "Whisper.cpp (local, no API key)", value: "whisper-cpp"}) + 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, install required)", value: "whisper-cpp-disabled"}) + options = append(options, optionItem{title: "Whisper.cpp (local)", desc: "Install whisper-cli to enable local models.", value: "whisper-cpp-disabled"}) } configured := getConfiguredProviders(cfg) @@ -842,18 +861,17 @@ func buildVoiceProviderOptions(cfg *config.Config) []optionItem { if p != nil && len(provider.ModelsOfType(p, provider.Transcription)) > 0 { switch name { case "openai": - options = append(options, optionItem{title: "OpenAI Whisper", value: "openai"}) + options = append(options, optionItem{title: "OpenAI Whisper", desc: "Configured. Balanced quality and cost.", 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"}, + optionItem{title: "Groq Whisper", desc: "Configured. Fast transcription.", value: "groq-transcription"}, ) case "mistral": - options = append(options, optionItem{title: "Mistral Voxtral", value: "mistral-transcription"}) + options = append(options, optionItem{title: "Mistral Voxtral", desc: "Configured. Strong European language support.", value: "mistral-transcription"}) case "elevenlabs": - options = append(options, optionItem{title: "ElevenLabs Scribe", value: "elevenlabs"}) + options = append(options, optionItem{title: "ElevenLabs Scribe", desc: "Configured. Best cloud quality.", value: "elevenlabs"}) case "deepgram": - options = append(options, optionItem{title: "Deepgram Nova", value: "deepgram"}) + options = append(options, optionItem{title: "Deepgram Nova", desc: "Configured. Great streaming performance.", value: "deepgram"}) } } } @@ -864,22 +882,21 @@ func buildVoiceProviderOptions(cfg *config.Config) []optionItem { } if !configuredSet["openai"] { - options = append(options, optionItem{title: "OpenAI Whisper (add API key)", value: "openai"}) + options = append(options, optionItem{title: "OpenAI Whisper", desc: "Requires API key. You'll be prompted.", 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"}, + optionItem{title: "Groq Whisper", desc: "Requires API key. You'll be prompted.", value: "groq-transcription"}, ) } if !configuredSet["mistral"] { - options = append(options, optionItem{title: "Mistral Voxtral (add API key)", value: "mistral-transcription"}) + options = append(options, optionItem{title: "Mistral Voxtral", desc: "Requires API key. You'll be prompted.", value: "mistral-transcription"}) } if !configuredSet["elevenlabs"] { - options = append(options, optionItem{title: "ElevenLabs Scribe (add API key)", value: "elevenlabs"}) + options = append(options, optionItem{title: "ElevenLabs Scribe", desc: "Requires API key. You'll be prompted.", value: "elevenlabs"}) } if !configuredSet["deepgram"] { - options = append(options, optionItem{title: "Deepgram Nova (add API key)", value: "deepgram"}) + options = append(options, optionItem{title: "Deepgram Nova", desc: "Requires API key. You'll be prompted.", value: "deepgram"}) } return options @@ -893,9 +910,9 @@ func buildLLMProviderOptions(cfg *config.Config) []optionItem { if p != nil && len(provider.ModelsOfType(p, provider.LLM)) > 0 { switch name { case "openai": - options = append(options, optionItem{title: "OpenAI GPT", value: "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 (fast)", value: "groq"}) + options = append(options, optionItem{title: "Groq Llama", desc: "Configured. Very fast inference.", value: "groq"}) } } } @@ -905,37 +922,58 @@ func buildLLMProviderOptions(cfg *config.Config) []optionItem { configuredSet[name] = true } if !configuredSet["openai"] { - options = append(options, optionItem{title: "OpenAI GPT (add API key)", value: "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 (add API key)", value: "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 { - 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) + return "OpenAI - Whisper + GPT" case "groq": - return fmt.Sprintf("Groq - Whisper + Llama %s", status) + return "Groq - Whisper + Llama" case "mistral": - return fmt.Sprintf("Mistral - Voxtral %s", status) + return "Mistral - Voxtral" case "elevenlabs": - return fmt.Sprintf("ElevenLabs - Scribe %s", status) + return "ElevenLabs - Scribe" case "deepgram": - return fmt.Sprintf("Deepgram - Nova %s", status) + return "Deepgram - Nova" default: - return fmt.Sprintf("%s %s", name, status) + 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 { @@ -1042,6 +1080,11 @@ func findMessageDef(key string) *notify.MessageDef { 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 diff --git a/internal/tui/helpers.go b/internal/tui/helpers.go index adcb9b1..aa28bba 100644 --- a/internal/tui/helpers.go +++ b/internal/tui/helpers.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "sort" + "strings" "github.com/leonardotrapani/hyprvoice/internal/config" "github.com/leonardotrapani/hyprvoice/internal/models/whisper" @@ -39,16 +40,6 @@ func maskAPIKey(key string) string { 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 { @@ -69,7 +60,7 @@ func isProviderConfigured(cfg *config.Config, providerName string) bool { func mapConfigProviderToRegistry(configProvider string) string { switch configProvider { - case "groq-transcription", "groq-translation": + case "groq-transcription": return "groq" case "mistral-transcription": return "mistral" @@ -78,27 +69,37 @@ func mapConfigProviderToRegistry(configProvider string) string { } } -func buildModelLabel(m provider.Model) string { - label := fmt.Sprintf("%s (%s)", m.Name, m.Description) +func buildModelDesc(m provider.Model) string { + parts := []string{} + if m.Description != "" { + parts = append(parts, m.Description) + } else if m.Name != "" { + parts = append(parts, m.Name) + } - if m.Local && m.LocalInfo != nil { - label += fmt.Sprintf(" [%s]", m.LocalInfo.Size) + if m.Local { + parts = append(parts, "local model") } if m.SupportsBothModes() { - label += " [batch+streaming]" + parts = append(parts, "batch+streaming") } else if m.SupportsStreaming { - label += " [streaming]" + parts = append(parts, "streaming") + } else { + parts = append(parts, "batch-only") } - return label + if m.Local && m.LocalInfo != nil && m.LocalInfo.Size != "" { + parts = append(parts, fmt.Sprintf("size %s", m.LocalInfo.Size)) + } + + if len(parts) == 0 { + return "Transcription model" + } + return strings.Join(parts, " - ") } 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 { @@ -108,15 +109,15 @@ func getTranscriptionModelOptions(configProvider string) []modelOption { models := provider.ModelsOfType(p, provider.Transcription) options := make([]modelOption, 0, len(models)) for _, m := range models { - label := buildModelLabel(m) + desc := buildModelDesc(m) if m.Local && registryName == "whisper-cpp" { if whisper.IsInstalled(m.ID) { - label = "[x] " + label + desc = desc + " - installed" } else { - label = "[ ] " + label + desc = desc + " - not installed" } } - options = append(options, modelOption{ID: m.ID, Label: label}) + options = append(options, modelOption{ID: m.ID, Title: m.ID, Desc: desc}) } return options diff --git a/internal/tui/screens.go b/internal/tui/screens.go index 58bda94..5893cb9 100644 --- a/internal/tui/screens.go +++ b/internal/tui/screens.go @@ -91,13 +91,18 @@ type confirmScreen struct { footer string onYes func() screen onNo func() screen + onBack func() screen errText string } -func newConfirmScreen(state *wizardState, title string, desc []string, yesLabel, noLabel string, onYes func() screen, onNo func() screen) *confirmScreen { +func newConfirmScreen(state *wizardState, title string, desc []string, yesLabel, yesDesc, noLabel, noDesc string, onYes func() screen, onNo func() screen, onBack func() screen) *confirmScreen { items := []optionItem{ - {title: yesLabel, value: "yes"}, - {title: noLabel, value: "no"}, + {title: yesLabel, desc: yesDesc, value: "yes"}, + {title: noLabel, desc: noDesc, value: "no"}, + } + footer := "enter select • esc cancel" + if onBack != nil { + footer = "enter select • esc back" } delegate := list.NewDefaultDelegate() l := list.New(itemsToList(items), delegate, 0, 0) @@ -106,7 +111,7 @@ func newConfirmScreen(state *wizardState, title string, desc []string, yesLabel, 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} + return &confirmScreen{state: state, title: title, desc: desc, list: l, footer: footer, onYes: onYes, onNo: onNo, onBack: onBack} } func (s *confirmScreen) Init() tea.Cmd { return nil } @@ -127,6 +132,9 @@ func (s *confirmScreen) Update(msg tea.Msg) (screen, tea.Cmd) { } } case "esc", "q": + if s.onBack != nil { + return s.onBack(), nil + } if s.onNo != nil { return s.onNo(), nil } @@ -314,7 +322,9 @@ func newInfoScreen(state *wizardState, title string, desc []string, next func() 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) Init() tea.Cmd { + return nil +} func (s *infoScreen) Update(msg tea.Msg) (screen, tea.Cmd) { switch msg := msg.(type) { diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 74b96ee..2f7747e 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -1,6 +1,10 @@ package tui -import "github.com/charmbracelet/lipgloss" +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) // Base styles for hyprvoice TUI components var ( @@ -60,14 +64,19 @@ var ( Padding(1, 2) ) -// Logo returns the hyprvoice ASCII art -func Logo() string { - logo := ` +const logoASCII = ` _ _ | |__ _ _ _ __ _ ____ _(_) ___ ___ | '_ \| | | | '_ \| '__\ \ / / |/ __/ _ \ | | | | |_| | |_) | | \ V /| | (_| __/ |_| |_|\__, | .__/|_| \_/ |_|\___\___| |___/|_| ` - return StyleHeader.Render(logo) + +// Logo returns the hyprvoice ASCII art +func Logo() string { + return StyleHeader.Render(strings.Trim(logoASCII, "\n")) +} + +func LogoLines() []string { + return strings.Split(strings.Trim(logoASCII, "\n"), "\n") } diff --git a/internal/tui/types.go b/internal/tui/types.go index bed04f8..682aafa 100644 --- a/internal/tui/types.go +++ b/internal/tui/types.go @@ -62,5 +62,6 @@ func (i toggleItem) FilterValue() string { type modelOption struct { ID string - Label string + Title string + Desc string } diff --git a/internal/tui/wizard.go b/internal/tui/wizard.go index 6ea0832..a2d9cb8 100644 --- a/internal/tui/wizard.go +++ b/internal/tui/wizard.go @@ -78,19 +78,16 @@ func (m wizardModel) View() string { } // Run starts the TUI configuration wizard. -// If onboarding is true, forces the guided wizard flow even if config exists. +// If onboarding is true, starts the guided onboarding flow. 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 - } + state := &wizardState{cfg: existingConfig, onboarding: onboarding} var start screen - if state.onboarding { + if onboarding { start = newWelcomeScreen(state) } else { start = newMenuScreen(state) diff --git a/packaging/hyprvoice.install b/packaging/hyprvoice.install index 0b82943..2fcee32 100644 --- a/packaging/hyprvoice.install +++ b/packaging/hyprvoice.install @@ -2,14 +2,16 @@ post_install() { echo "==> Hyprvoice installed successfully!" echo "" echo " 📋 To use hyprvoice:" - echo " 1. Configure: hyprvoice configure" - echo " 2. Enable service: systemctl --user enable hyprvoice.service" - echo " 3. Start service: systemctl --user start hyprvoice.service" - echo " 4. Add keybinding to your window manager" + echo " 1. Run onboarding: hyprvoice onboarding" + echo " 2. Enable service: systemctl --user enable --now hyprvoice.service" + echo " 3. Add keybinding to your window manager" + echo " 4. Test voice input: hyprvoice toggle" echo "" echo " 🔑 For Hyprland, add to ~/.config/hypr/hyprland.conf:" echo " bind = SUPER, R, exec, hyprvoice toggle" echo "" + echo " Later: hyprvoice configure for advanced settings" + echo "" } post_upgrade() {