feat: use new ui bubbletea
This commit is contained in:
@@ -4,11 +4,11 @@ go 1.24.5
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.5.0
|
github.com/BurntSushi/toml v1.5.0
|
||||||
github.com/charmbracelet/huh v0.8.0
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/muesli/termenv v0.16.0
|
|
||||||
github.com/sashabaranov/go-openai v1.41.1
|
github.com/sashabaranov/go-openai v1.41.1
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
golang.org/x/text v0.23.0
|
golang.org/x/text v0.23.0
|
||||||
@@ -17,26 +17,23 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/catppuccin/go v0.3.0 // indirect
|
|
||||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||||
golang.org/x/sys v0.36.0 // indirect
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,48 +1,28 @@
|
|||||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
|
||||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
|
||||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
|
||||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||||
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
|
|
||||||
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
|
||||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
|
||||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
|
||||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
|
||||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
|
||||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
|
||||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
|
||||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
@@ -51,6 +31,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
|||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
@@ -59,8 +41,6 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
|
|||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
@@ -71,6 +51,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
|
|||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||||
|
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||||
github.com/sashabaranov/go-openai v1.41.1 h1:zf5tM+GuxpyiyD9XZg8nCqu52eYFQg9OOew0gnIuDy4=
|
github.com/sashabaranov/go-openai v1.41.1 h1:zf5tM+GuxpyiyD9XZg8nCqu52eYFQg9OOew0gnIuDy4=
|
||||||
github.com/sashabaranov/go-openai v1.41.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
github.com/sashabaranov/go-openai v1.41.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
||||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
|
|||||||
@@ -207,10 +207,15 @@ func ValidateModelLanguageCompatibility(registryProvider, modelID, langCode stri
|
|||||||
docsHint = fmt.Sprintf(" See %s for full list.", model.DocsURL)
|
docsHint = fmt.Sprintf(" See %s for full list.", model.DocsURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
langLabel := provider.LanguageLabel(langCode)
|
||||||
|
if langLabel == "" {
|
||||||
|
langLabel = fmt.Sprintf("language '%s'", langCode)
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"model %s does not support language '%s'.%s Supported: %s%s",
|
"model %s does not support %s.%s Supported: %s%s",
|
||||||
model.Name,
|
model.Name,
|
||||||
langCode,
|
langLabel,
|
||||||
docsHint,
|
docsHint,
|
||||||
strings.Join(supported, ", "),
|
strings.Join(supported, ", "),
|
||||||
suffix,
|
suffix,
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
"golang.org/x/text/language/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LanguageLabel returns a human-readable label for a language code.
|
||||||
|
// Example: "es" -> "Spanish (es)", "en-US" -> "English (United States) (en-US)".
|
||||||
|
func LanguageLabel(code string) string {
|
||||||
|
if code == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := strings.ReplaceAll(code, "_", "-")
|
||||||
|
tag, err := language.Parse(normalized)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("language '%s'", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := display.English.Tags().Name(tag)
|
||||||
|
if name == "" || strings.EqualFold(name, code) {
|
||||||
|
return fmt.Sprintf("language '%s'", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s (%s)", name, code)
|
||||||
|
}
|
||||||
@@ -163,10 +163,15 @@ func ValidateModelLanguage(providerName, modelID, langCode string) error {
|
|||||||
docsHint = fmt.Sprintf(" See %s for full list.", model.DocsURL)
|
docsHint = fmt.Sprintf(" See %s for full list.", model.DocsURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
langLabel := LanguageLabel(langCode)
|
||||||
|
if langLabel == "" {
|
||||||
|
langLabel = fmt.Sprintf("language '%s'", langCode)
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"model %s does not support language '%s'.%s Supported: %s%s",
|
"model %s does not support %s.%s Supported: %s%s",
|
||||||
model.Name,
|
model.Name,
|
||||||
langCode,
|
langLabel,
|
||||||
docsHint,
|
docsHint,
|
||||||
strings.Join(supported, ", "),
|
strings.Join(supported, ", "),
|
||||||
suffix,
|
suffix,
|
||||||
|
|||||||
@@ -283,9 +283,9 @@ func TestValidateModelLanguage_ErrorFormat(t *testing.T) {
|
|||||||
t.Errorf("error should contain docs URL, got: %s", errMsg)
|
t.Errorf("error should contain docs URL, got: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// should contain language code
|
// should contain language label
|
||||||
if !strings.Contains(errMsg, "'es'") {
|
if !strings.Contains(errMsg, "Spanish (es)") {
|
||||||
t.Errorf("error should contain language code, got: %s", errMsg)
|
t.Errorf("error should contain language label, got: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// should contain supported languages (English-only has just 'en')
|
// should contain supported languages (English-only has just 'en')
|
||||||
@@ -387,10 +387,11 @@ func TestElevenLabsProvider(t *testing.T) {
|
|||||||
t.Errorf("scribe_v2_realtime AdapterType=%q, want 'elevenlabs-streaming'", scribeV2Realtime.AdapterType)
|
t.Errorf("scribe_v2_realtime AdapterType=%q, want 'elevenlabs-streaming'", scribeV2Realtime.AdapterType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// All models have explicit SupportedLanguages from docs (subset of our 57)
|
// All models should share the same supported language list
|
||||||
|
wantLangCount := len(elevenLabsTranscriptionLanguages)
|
||||||
for _, m := range models {
|
for _, m := range models {
|
||||||
if len(m.SupportedLanguages) != 57 {
|
if len(m.SupportedLanguages) != wantLangCount {
|
||||||
t.Errorf("model %q has %d languages, want 57", m.ID, len(m.SupportedLanguages))
|
t.Errorf("model %q has %d languages, want %d", m.ID, len(m.SupportedLanguages), wantLangCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,8 +230,9 @@ func (a *DeepgramAdapter) buildURL() (string, error) {
|
|||||||
q.Set("punctuate", "true")
|
q.Set("punctuate", "true")
|
||||||
|
|
||||||
// add language if specified
|
// add language if specified
|
||||||
if a.language != "" {
|
lang := normalizeDeepgramLanguage(a.language)
|
||||||
q.Set("language", a.language)
|
if lang != "" {
|
||||||
|
q.Set("language", lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(a.keywords) > 0 {
|
if len(a.keywords) > 0 {
|
||||||
|
|||||||
@@ -118,8 +118,9 @@ func (a *DeepgramBatchAdapter) buildURL() (string, error) {
|
|||||||
q.Set("punctuate", "true")
|
q.Set("punctuate", "true")
|
||||||
|
|
||||||
// add language if specified
|
// add language if specified
|
||||||
if a.language != "" {
|
lang := normalizeDeepgramLanguage(a.language)
|
||||||
q.Set("language", a.language)
|
if lang != "" {
|
||||||
|
q.Set("language", lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(a.keywords) > 0 {
|
if len(a.keywords) > 0 {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package transcriber
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
func normalizeDeepgramLanguage(code string) string {
|
||||||
|
if code == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.EqualFold(code, "en") || strings.EqualFold(code, "en-us") || strings.EqualFold(code, "en_us") {
|
||||||
|
return "en-US"
|
||||||
|
}
|
||||||
|
|
||||||
|
return code
|
||||||
|
}
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/config"
|
|
||||||
"github.com/muesli/termenv"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ConfigureResult holds the configuration result from the TUI
|
|
||||||
type ConfigureResult struct {
|
|
||||||
Config *config.Config
|
|
||||||
Cancelled bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllProviders is the list of all supported cloud providers (require API keys)
|
|
||||||
var AllProviders = []string{"openai", "groq", "mistral", "elevenlabs", "deepgram"}
|
|
||||||
|
|
||||||
// LocalProviders is the list of local providers (no API key required)
|
|
||||||
var LocalProviders = []string{"whisper-cpp"}
|
|
||||||
|
|
||||||
// providerDisplayNames maps provider IDs to human-readable names
|
|
||||||
var providerDisplayNames = map[string]string{
|
|
||||||
"openai": "OpenAI",
|
|
||||||
"groq": "Groq",
|
|
||||||
"mistral": "Mistral",
|
|
||||||
"elevenlabs": "ElevenLabs",
|
|
||||||
"deepgram": "Deepgram",
|
|
||||||
"whisper-cpp": "Whisper.cpp (local)",
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConfigSection represents a configuration section
|
|
||||||
type ConfigSection string
|
|
||||||
|
|
||||||
const (
|
|
||||||
SectionProviders ConfigSection = "providers"
|
|
||||||
SectionTranscription ConfigSection = "transcription"
|
|
||||||
SectionLLM ConfigSection = "llm"
|
|
||||||
SectionKeywords ConfigSection = "keywords"
|
|
||||||
SectionInjection ConfigSection = "injection"
|
|
||||||
SectionNotifications ConfigSection = "notifications"
|
|
||||||
SectionAdvanced ConfigSection = "advanced"
|
|
||||||
SectionSaveExit ConfigSection = "save_exit"
|
|
||||||
SectionDiscardExit ConfigSection = "discard_exit"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Run starts the TUI configuration wizard
|
|
||||||
// If onboarding is true, forces the guided wizard flow even if config exists
|
|
||||||
func Run(existingConfig *config.Config, onboarding bool) (*ConfigureResult, error) {
|
|
||||||
if !onboarding && existingConfig != nil && hasUserChanges(existingConfig) {
|
|
||||||
return runEditExisting(existingConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := runFreshInstall(existingConfig)
|
|
||||||
if err != nil || result.Cancelled {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// wizard done, transition to menu for review/save
|
|
||||||
return runEditExisting(result.Config)
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasUserChanges detects if config has user modifications
|
|
||||||
func hasUserChanges(cfg *config.Config) bool {
|
|
||||||
if len(cfg.Providers) > 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if cfg.Transcription.APIKey != "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// runEditExisting runs the menu-based edit flow for existing configs
|
|
||||||
func runEditExisting(cfg *config.Config) (*ConfigureResult, error) {
|
|
||||||
fmt.Println(Logo())
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
configuredProviders := getConfiguredProviders(cfg)
|
|
||||||
|
|
||||||
for {
|
|
||||||
clearScreen()
|
|
||||||
fmt.Println(Logo())
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
section, err := selectSection(cfg)
|
|
||||||
if err != nil {
|
|
||||||
return &ConfigureResult{Cancelled: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch section {
|
|
||||||
case SectionSaveExit:
|
|
||||||
confirmed, err := showSummary(cfg)
|
|
||||||
if err != nil {
|
|
||||||
return &ConfigureResult{Cancelled: true}, nil
|
|
||||||
}
|
|
||||||
if confirmed {
|
|
||||||
return &ConfigureResult{Config: cfg, Cancelled: false}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
case SectionDiscardExit:
|
|
||||||
return &ConfigureResult{Cancelled: true}, nil
|
|
||||||
|
|
||||||
case SectionProviders:
|
|
||||||
if err := editProviders(cfg, false); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
configuredProviders = getConfiguredProviders(cfg)
|
|
||||||
|
|
||||||
case SectionTranscription:
|
|
||||||
var err error
|
|
||||||
configuredProviders, err = editTranscription(cfg, configuredProviders)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
case SectionLLM:
|
|
||||||
var err error
|
|
||||||
configuredProviders, err = editLLM(cfg, configuredProviders)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
case SectionKeywords:
|
|
||||||
keywords, err := inputKeywords(cfg.Keywords)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cfg.Keywords = keywords
|
|
||||||
|
|
||||||
case SectionInjection:
|
|
||||||
backends, err := selectBackends(cfg.Injection.Backends)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cfg.Injection.Backends = backends
|
|
||||||
|
|
||||||
case SectionNotifications:
|
|
||||||
if err := editNotifications(cfg); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
case SectionAdvanced:
|
|
||||||
if err := editAdvanced(cfg, false); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectSection(cfg *config.Config) (ConfigSection, error) {
|
|
||||||
options := []huh.Option[ConfigSection]{
|
|
||||||
huh.NewOption(formatProvidersLabel(cfg), SectionProviders),
|
|
||||||
huh.NewOption(formatTranscriptionLabel(cfg), SectionTranscription),
|
|
||||||
huh.NewOption(formatLLMLabel(cfg), SectionLLM),
|
|
||||||
huh.NewOption(formatKeywordsLabel(cfg), SectionKeywords),
|
|
||||||
huh.NewOption(formatInjectionLabel(cfg), SectionInjection),
|
|
||||||
huh.NewOption(formatNotificationsLabel(cfg), SectionNotifications),
|
|
||||||
huh.NewOption("Advanced Settings", SectionAdvanced),
|
|
||||||
huh.NewOption("Save & Exit", SectionSaveExit),
|
|
||||||
huh.NewOption("Discard & Exit", SectionDiscardExit),
|
|
||||||
}
|
|
||||||
|
|
||||||
var selected ConfigSection
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewSelect[ConfigSection]().
|
|
||||||
Title("Configuration Menu").
|
|
||||||
Description("↑/↓ navigate • enter select • esc cancel").
|
|
||||||
Options(options...).
|
|
||||||
Value(&selected),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return selected, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// clearScreen clears the terminal screen
|
|
||||||
func clearScreen() {
|
|
||||||
output := termenv.NewOutput(os.Stdout)
|
|
||||||
output.ClearScreen()
|
|
||||||
}
|
|
||||||
|
|
||||||
func getTheme() *huh.Theme {
|
|
||||||
t := huh.ThemeBase()
|
|
||||||
|
|
||||||
t.Focused.Title = lipgloss.NewStyle().Foreground(ColorPrimary).Bold(true)
|
|
||||||
t.Focused.Description = lipgloss.NewStyle().Foreground(ColorMuted)
|
|
||||||
t.Focused.Base = lipgloss.NewStyle().BorderForeground(ColorPrimary)
|
|
||||||
t.Focused.SelectedOption = lipgloss.NewStyle().Foreground(ColorSecondary)
|
|
||||||
t.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(ColorText)
|
|
||||||
|
|
||||||
t.Blurred.Title = lipgloss.NewStyle().Foreground(ColorMuted)
|
|
||||||
t.Blurred.Description = lipgloss.NewStyle().Foreground(ColorSubtle)
|
|
||||||
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AdvancedSection represents a section in the advanced settings menu
|
|
||||||
type AdvancedSection string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AdvancedRecording AdvancedSection = "recording"
|
|
||||||
AdvancedInjectionTimeout AdvancedSection = "injection_timeout"
|
|
||||||
AdvancedBack AdvancedSection = "back"
|
|
||||||
)
|
|
||||||
|
|
||||||
// editAdvanced handles the advanced settings submenu
|
|
||||||
func editAdvanced(cfg *config.Config, onboarding bool) error {
|
|
||||||
exitLabel := "Done"
|
|
||||||
if onboarding {
|
|
||||||
exitLabel = "Next"
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
options := []huh.Option[AdvancedSection]{
|
|
||||||
huh.NewOption(formatAdvancedRecordingLabel(cfg), AdvancedRecording),
|
|
||||||
huh.NewOption(formatAdvancedInjectionTimeoutLabel(cfg), AdvancedInjectionTimeout),
|
|
||||||
huh.NewOption(exitLabel, AdvancedBack),
|
|
||||||
}
|
|
||||||
|
|
||||||
var selected AdvancedSection
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewSelect[AdvancedSection]().
|
|
||||||
Title("Advanced Settings").
|
|
||||||
Description("Configure low-level options").
|
|
||||||
Options(options...).
|
|
||||||
Value(&selected),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch selected {
|
|
||||||
case AdvancedBack:
|
|
||||||
return nil
|
|
||||||
case AdvancedRecording:
|
|
||||||
if err := editRecording(cfg); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
case AdvancedInjectionTimeout:
|
|
||||||
if err := editInjectionTimeouts(cfg); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatAdvancedRecordingLabel(cfg *config.Config) string {
|
|
||||||
return fmt.Sprintf("Recording Settings (rate=%d, timeout=%s)", cfg.Recording.SampleRate, cfg.Recording.Timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatAdvancedInjectionTimeoutLabel(cfg *config.Config) string {
|
|
||||||
return fmt.Sprintf("Injection Timeouts (ydotool=%s, wtype=%s, clipboard=%s)",
|
|
||||||
cfg.Injection.YdotoolTimeout, cfg.Injection.WtypeTimeout, cfg.Injection.ClipboardTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// editRecording handles the recording settings
|
|
||||||
func editRecording(cfg *config.Config) error {
|
|
||||||
sampleRate := strconv.Itoa(cfg.Recording.SampleRate)
|
|
||||||
channels := strconv.Itoa(cfg.Recording.Channels)
|
|
||||||
format := cfg.Recording.Format
|
|
||||||
bufferSize := strconv.Itoa(cfg.Recording.BufferSize)
|
|
||||||
device := cfg.Recording.Device
|
|
||||||
channelBufferSize := strconv.Itoa(cfg.Recording.ChannelBufferSize)
|
|
||||||
timeout := cfg.Recording.Timeout.String()
|
|
||||||
|
|
||||||
channelOptions := []huh.Option[string]{
|
|
||||||
huh.NewOption("1 (Mono) - Recommended", "1"),
|
|
||||||
huh.NewOption("2 (Stereo)", "2"),
|
|
||||||
}
|
|
||||||
|
|
||||||
formatOptions := []huh.Option[string]{
|
|
||||||
huh.NewOption("s16 (16-bit signed) - Recommended", "s16"),
|
|
||||||
huh.NewOption("f32 (32-bit float)", "f32"),
|
|
||||||
}
|
|
||||||
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Sample Rate (Hz)").
|
|
||||||
Description("Audio sample rate. 16000 is optimal for speech recognition.").
|
|
||||||
Placeholder("16000").
|
|
||||||
Value(&sampleRate).
|
|
||||||
Validate(func(s string) error {
|
|
||||||
if _, err := strconv.Atoi(s); err != nil {
|
|
||||||
return fmt.Errorf("must be a number")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
huh.NewSelect[string]().
|
|
||||||
Title("Channels").
|
|
||||||
Description("Number of audio channels").
|
|
||||||
Options(channelOptions...).
|
|
||||||
Value(&channels),
|
|
||||||
huh.NewSelect[string]().
|
|
||||||
Title("Audio Format").
|
|
||||||
Description("Sample format").
|
|
||||||
Options(formatOptions...).
|
|
||||||
Value(&format),
|
|
||||||
),
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Buffer Size (bytes)").
|
|
||||||
Description("Internal buffer size. Larger = less CPU, more latency.").
|
|
||||||
Placeholder("8192").
|
|
||||||
Value(&bufferSize).
|
|
||||||
Validate(func(s string) error {
|
|
||||||
if _, err := strconv.Atoi(s); err != nil {
|
|
||||||
return fmt.Errorf("must be a number")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Channel Buffer Size").
|
|
||||||
Description("Number of audio frames to buffer.").
|
|
||||||
Placeholder("30").
|
|
||||||
Value(&channelBufferSize).
|
|
||||||
Validate(func(s string) error {
|
|
||||||
if _, err := strconv.Atoi(s); err != nil {
|
|
||||||
return fmt.Errorf("must be a number")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Device").
|
|
||||||
Description("PipeWire device name. Empty = default microphone.").
|
|
||||||
Placeholder("(default)").
|
|
||||||
Value(&device),
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Recording Timeout").
|
|
||||||
Description("Max recording duration (e.g., '30s', '2m', '5m'). Prevents runaway recordings.").
|
|
||||||
Placeholder("5m").
|
|
||||||
Value(&timeout).
|
|
||||||
Validate(func(s string) error {
|
|
||||||
if _, err := time.ParseDuration(s); err != nil {
|
|
||||||
return fmt.Errorf("invalid duration format (use '30s', '2m', etc.)")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.Recording.SampleRate, _ = strconv.Atoi(sampleRate)
|
|
||||||
cfg.Recording.Channels, _ = strconv.Atoi(channels)
|
|
||||||
cfg.Recording.Format = format
|
|
||||||
cfg.Recording.BufferSize, _ = strconv.Atoi(bufferSize)
|
|
||||||
cfg.Recording.Device = device
|
|
||||||
cfg.Recording.ChannelBufferSize, _ = strconv.Atoi(channelBufferSize)
|
|
||||||
cfg.Recording.Timeout, _ = time.ParseDuration(timeout)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// editInjectionTimeouts handles the injection timeout settings
|
|
||||||
func editInjectionTimeouts(cfg *config.Config) error {
|
|
||||||
ydotoolTimeout := cfg.Injection.YdotoolTimeout.String()
|
|
||||||
wtypeTimeout := cfg.Injection.WtypeTimeout.String()
|
|
||||||
clipboardTimeout := cfg.Injection.ClipboardTimeout.String()
|
|
||||||
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewInput().
|
|
||||||
Title("ydotool Timeout").
|
|
||||||
Description("Timeout for ydotool commands (e.g., '5s', '10s')").
|
|
||||||
Placeholder("5s").
|
|
||||||
Value(&ydotoolTimeout).
|
|
||||||
Validate(func(s string) error {
|
|
||||||
if _, err := time.ParseDuration(s); err != nil {
|
|
||||||
return fmt.Errorf("invalid duration format")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
huh.NewInput().
|
|
||||||
Title("wtype Timeout").
|
|
||||||
Description("Timeout for wtype commands (e.g., '5s', '10s')").
|
|
||||||
Placeholder("5s").
|
|
||||||
Value(&wtypeTimeout).
|
|
||||||
Validate(func(s string) error {
|
|
||||||
if _, err := time.ParseDuration(s); err != nil {
|
|
||||||
return fmt.Errorf("invalid duration format")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Clipboard Timeout").
|
|
||||||
Description("Timeout for clipboard operations (e.g., '3s', '5s')").
|
|
||||||
Placeholder("3s").
|
|
||||||
Value(&clipboardTimeout).
|
|
||||||
Validate(func(s string) error {
|
|
||||||
if _, err := time.ParseDuration(s); err != nil {
|
|
||||||
return fmt.Errorf("invalid duration format")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.Injection.YdotoolTimeout, _ = time.ParseDuration(ydotoolTimeout)
|
|
||||||
cfg.Injection.WtypeTimeout, _ = time.ParseDuration(wtypeTimeout)
|
|
||||||
cfg.Injection.ClipboardTimeout, _ = time.ParseDuration(clipboardTimeout)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// formatProvidersLabel formats the providers menu option
|
|
||||||
func formatProvidersLabel(cfg *config.Config) string {
|
|
||||||
return "Providers"
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatTranscriptionLabel formats the transcription menu option
|
|
||||||
func formatTranscriptionLabel(cfg *config.Config) string {
|
|
||||||
return "Transcription"
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatLLMLabel formats the LLM menu option
|
|
||||||
func formatLLMLabel(cfg *config.Config) string {
|
|
||||||
return "LLM"
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatKeywordsLabel formats the keywords menu option
|
|
||||||
func formatKeywordsLabel(cfg *config.Config) string {
|
|
||||||
return "Keywords"
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatInjectionLabel formats the injection menu option
|
|
||||||
func formatInjectionLabel(cfg *config.Config) string {
|
|
||||||
return "Injection"
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatNotificationsLabel formats the notifications menu option
|
|
||||||
func formatNotificationsLabel(cfg *config.Config) string {
|
|
||||||
return "Notifications"
|
|
||||||
}
|
|
||||||
|
|
||||||
func showSummary(cfg *config.Config) (bool, error) {
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println(StyleHeader.Render("Configuration Summary"))
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
var providers []string
|
|
||||||
for name := range cfg.Providers {
|
|
||||||
providers = append(providers, name)
|
|
||||||
}
|
|
||||||
fmt.Printf(" %s %s\n", StyleLabel.Render("Providers:"), strings.Join(providers, ", "))
|
|
||||||
|
|
||||||
lang := cfg.Transcription.Language
|
|
||||||
if lang == "" {
|
|
||||||
lang = "auto-detect"
|
|
||||||
}
|
|
||||||
fmt.Printf(" %s %s/%s (%s)\n", StyleLabel.Render("Transcription:"), cfg.Transcription.Provider, cfg.Transcription.Model, lang)
|
|
||||||
|
|
||||||
if cfg.LLM.Enabled {
|
|
||||||
fmt.Printf(" %s %s (%s)\n", StyleLabel.Render("LLM:"), cfg.LLM.Provider, cfg.LLM.Model)
|
|
||||||
var ppOpts []string
|
|
||||||
if cfg.LLM.PostProcessing.RemoveStutters {
|
|
||||||
ppOpts = append(ppOpts, "remove stutters")
|
|
||||||
}
|
|
||||||
if cfg.LLM.PostProcessing.AddPunctuation {
|
|
||||||
ppOpts = append(ppOpts, "add punctuation")
|
|
||||||
}
|
|
||||||
if cfg.LLM.PostProcessing.FixGrammar {
|
|
||||||
ppOpts = append(ppOpts, "fix grammar")
|
|
||||||
}
|
|
||||||
if cfg.LLM.PostProcessing.RemoveFillerWords {
|
|
||||||
ppOpts = append(ppOpts, "remove fillers")
|
|
||||||
}
|
|
||||||
if len(ppOpts) > 0 {
|
|
||||||
fmt.Printf(" %s %s\n", StyleLabel.Render("Post-processing:"), strings.Join(ppOpts, ", "))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fmt.Printf(" %s disabled\n", StyleLabel.Render("LLM:"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cfg.Keywords) > 0 {
|
|
||||||
fmt.Printf(" %s %s\n", StyleLabel.Render("Keywords:"), strings.Join(cfg.Keywords, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf(" %s %s\n", StyleLabel.Render("Backends:"), strings.Join(cfg.Injection.Backends, " -> "))
|
|
||||||
|
|
||||||
if cfg.Notifications.Enabled {
|
|
||||||
fmt.Printf(" %s enabled\n", StyleLabel.Render("Notifications:"))
|
|
||||||
} else {
|
|
||||||
fmt.Printf(" %s disabled\n", StyleLabel.Render("Notifications:"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
var confirmed bool
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewConfirm().
|
|
||||||
Title("Save this configuration?").
|
|
||||||
Affirmative("Save").
|
|
||||||
Negative("Cancel").
|
|
||||||
Value(&confirmed),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return confirmed, nil
|
|
||||||
}
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/config"
|
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
|
||||||
)
|
|
||||||
|
|
||||||
// buildLLMModelLabel creates the display label for an LLM model option
|
|
||||||
func buildLLMModelLabel(m provider.Model) string {
|
|
||||||
return fmt.Sprintf("%s (%s)", m.Name, m.Description)
|
|
||||||
}
|
|
||||||
|
|
||||||
// editLLM handles the LLM section edit with smart provider detection
|
|
||||||
func editLLM(cfg *config.Config, configuredProviders []string) ([]string, error) {
|
|
||||||
var llmProviders []string
|
|
||||||
for _, name := range configuredProviders {
|
|
||||||
p := provider.GetProvider(name)
|
|
||||||
if p != nil && len(provider.ModelsOfType(p, provider.LLM)) > 0 {
|
|
||||||
llmProviders = append(llmProviders, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
postProcessing := cfg.LLM.PostProcessing
|
|
||||||
if !postProcessing.RemoveStutters && !postProcessing.AddPunctuation &&
|
|
||||||
!postProcessing.FixGrammar && !postProcessing.RemoveFillerWords {
|
|
||||||
postProcessing = config.LLMPostProcessingConfig{
|
|
||||||
RemoveStutters: true,
|
|
||||||
AddPunctuation: true,
|
|
||||||
FixGrammar: true,
|
|
||||||
RemoveFillerWords: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
customPrompt := cfg.LLM.CustomPrompt
|
|
||||||
|
|
||||||
enableLLM := cfg.LLM.Enabled
|
|
||||||
|
|
||||||
enableDesc := "LLM improves transcription by fixing grammar, removing stutters, and cleaning up text. Recommended for weak voice models"
|
|
||||||
if cfg.LLM.Enabled {
|
|
||||||
enableDesc = fmt.Sprintf("Currently: enabled (%s/%s). %s", cfg.LLM.Provider, cfg.LLM.Model, enableDesc)
|
|
||||||
} else {
|
|
||||||
enableDesc = "Currently: disabled. " + enableDesc
|
|
||||||
}
|
|
||||||
|
|
||||||
enableForm := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewConfirm().
|
|
||||||
Title("Enable LLM Post-Processing? (Recommended)").
|
|
||||||
Description(enableDesc).
|
|
||||||
Affirmative("Yes (Recommended)").
|
|
||||||
Negative("No").
|
|
||||||
Value(&enableLLM),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := enableForm.Run(); err != nil {
|
|
||||||
return configuredProviders, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !enableLLM {
|
|
||||||
cfg.LLM.Enabled = false
|
|
||||||
return configuredProviders, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var llmOptions []huh.Option[string]
|
|
||||||
for _, name := range llmProviders {
|
|
||||||
switch name {
|
|
||||||
case "openai":
|
|
||||||
llmOptions = append(llmOptions, huh.NewOption("OpenAI GPT", "openai"))
|
|
||||||
case "groq":
|
|
||||||
llmOptions = append(llmOptions, huh.NewOption("Groq Llama (fast)", "groq"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
unconfiguredLLM := getUnconfiguredLLMOptions(configuredProviders)
|
|
||||||
if len(unconfiguredLLM) > 0 {
|
|
||||||
llmOptions = append(llmOptions, unconfiguredLLM...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(llmOptions) == 0 {
|
|
||||||
fmt.Println(StyleError.Render("No LLM providers available. Please configure OpenAI or Groq first."))
|
|
||||||
cfg.LLM.Enabled = false
|
|
||||||
return configuredProviders, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedProvider := cfg.LLM.Provider
|
|
||||||
if selectedProvider == "" && len(llmOptions) > 0 {
|
|
||||||
selectedProvider = llmOptions[0].Value
|
|
||||||
}
|
|
||||||
|
|
||||||
llmProviderDesc := "Choose which service to use for text post-processing"
|
|
||||||
if cfg.LLM.Provider != "" {
|
|
||||||
llmProviderDesc = fmt.Sprintf("Currently: %s/%s", cfg.LLM.Provider, cfg.LLM.Model)
|
|
||||||
}
|
|
||||||
|
|
||||||
providerForm := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewSelect[string]().
|
|
||||||
Title("LLM Provider").
|
|
||||||
Description(llmProviderDesc).
|
|
||||||
Options(llmOptions...).
|
|
||||||
Value(&selectedProvider),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := providerForm.Run(); err != nil {
|
|
||||||
return configuredProviders, err
|
|
||||||
}
|
|
||||||
|
|
||||||
configuredProviders = ensureProviderConfigured(cfg, selectedProvider, configuredProviders)
|
|
||||||
cfg.LLM.Provider = selectedProvider
|
|
||||||
|
|
||||||
modelOptions := getLLMModelOptions(selectedProvider)
|
|
||||||
selectedModel := cfg.LLM.Model
|
|
||||||
if selectedModel == "" && len(modelOptions) > 0 {
|
|
||||||
selectedModel = modelOptions[0].Value
|
|
||||||
}
|
|
||||||
|
|
||||||
llmModelDesc := ""
|
|
||||||
if cfg.LLM.Model != "" {
|
|
||||||
llmModelDesc = fmt.Sprintf("Currently: %s", cfg.LLM.Model)
|
|
||||||
}
|
|
||||||
|
|
||||||
modelForm := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewSelect[string]().
|
|
||||||
Title("LLM Model").
|
|
||||||
Description(llmModelDesc).
|
|
||||||
Options(modelOptions...).
|
|
||||||
Value(&selectedModel),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := modelForm.Run(); err != nil {
|
|
||||||
return configuredProviders, err
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.LLM.Model = selectedModel
|
|
||||||
|
|
||||||
var ppErr error
|
|
||||||
postProcessing, ppErr = selectPostProcessingOptions(postProcessing)
|
|
||||||
if ppErr != nil {
|
|
||||||
return configuredProviders, ppErr
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.LLM.PostProcessing = postProcessing
|
|
||||||
|
|
||||||
enableCustomPrompt := customPrompt.Enabled
|
|
||||||
customPromptText := customPrompt.Prompt
|
|
||||||
|
|
||||||
customPromptDesc := "Add extra instructions for the LLM"
|
|
||||||
if customPrompt.Enabled && customPrompt.Prompt != "" {
|
|
||||||
preview := customPrompt.Prompt
|
|
||||||
if len(preview) > 40 {
|
|
||||||
preview = preview[:40] + "..."
|
|
||||||
}
|
|
||||||
customPromptDesc = fmt.Sprintf("Currently: \"%s\"", preview)
|
|
||||||
} else {
|
|
||||||
customPromptDesc = "Currently: none. " + customPromptDesc
|
|
||||||
}
|
|
||||||
|
|
||||||
customForm := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewConfirm().
|
|
||||||
Title("Add custom prompt?").
|
|
||||||
Description(customPromptDesc).
|
|
||||||
Value(&enableCustomPrompt),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := customForm.Run(); err != nil {
|
|
||||||
return configuredProviders, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if enableCustomPrompt {
|
|
||||||
promptForm := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewText().
|
|
||||||
Title("Custom Prompt").
|
|
||||||
Description("Additional instructions (e.g., 'Format as bullet points')").
|
|
||||||
Value(&customPromptText).
|
|
||||||
CharLimit(500),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := promptForm.Run(); err != nil {
|
|
||||||
return configuredProviders, err
|
|
||||||
}
|
|
||||||
cfg.LLM.CustomPrompt.Enabled = true
|
|
||||||
cfg.LLM.CustomPrompt.Prompt = customPromptText
|
|
||||||
} else {
|
|
||||||
cfg.LLM.CustomPrompt.Enabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.LLM.Enabled = true
|
|
||||||
return configuredProviders, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getUnconfiguredLLMOptions returns options for LLM providers not yet configured
|
|
||||||
func getUnconfiguredLLMOptions(configuredProviders []string) []huh.Option[string] {
|
|
||||||
configured := make(map[string]bool)
|
|
||||||
for _, p := range configuredProviders {
|
|
||||||
configured[p] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var options []huh.Option[string]
|
|
||||||
if !configured["openai"] {
|
|
||||||
options = append(options, huh.NewOption("OpenAI GPT (not configured)", "openai"))
|
|
||||||
}
|
|
||||||
if !configured["groq"] {
|
|
||||||
options = append(options, huh.NewOption("Groq Llama (not configured)", "groq"))
|
|
||||||
}
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
func getLLMModelOptions(providerName string) []huh.Option[string] {
|
|
||||||
p := provider.GetProvider(providerName)
|
|
||||||
if p == nil {
|
|
||||||
return []huh.Option[string]{}
|
|
||||||
}
|
|
||||||
|
|
||||||
models := provider.ModelsOfType(p, provider.LLM)
|
|
||||||
var options []huh.Option[string]
|
|
||||||
|
|
||||||
for _, m := range models {
|
|
||||||
label := buildLLMModelLabel(m)
|
|
||||||
options = append(options, huh.NewOption(label, m.ID))
|
|
||||||
}
|
|
||||||
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
// selectPostProcessingOptions shows a multi-select for LLM post-processing toggles
|
|
||||||
func selectPostProcessingOptions(current config.LLMPostProcessingConfig) (config.LLMPostProcessingConfig, error) {
|
|
||||||
type ppOption string
|
|
||||||
const (
|
|
||||||
optRemoveStutters ppOption = "stutters"
|
|
||||||
optAddPunctuation ppOption = "punctuation"
|
|
||||||
optFixGrammar ppOption = "grammar"
|
|
||||||
optRemoveFillerWords ppOption = "fillers"
|
|
||||||
)
|
|
||||||
|
|
||||||
options := []huh.Option[ppOption]{
|
|
||||||
huh.NewOption("Remove stutters (repeated words)", optRemoveStutters),
|
|
||||||
huh.NewOption("Add punctuation", optAddPunctuation),
|
|
||||||
huh.NewOption("Fix grammar", optFixGrammar),
|
|
||||||
huh.NewOption("Remove filler words (um, uh, like)", optRemoveFillerWords),
|
|
||||||
}
|
|
||||||
|
|
||||||
var selected []ppOption
|
|
||||||
if current.RemoveStutters {
|
|
||||||
selected = append(selected, optRemoveStutters)
|
|
||||||
}
|
|
||||||
if current.AddPunctuation {
|
|
||||||
selected = append(selected, optAddPunctuation)
|
|
||||||
}
|
|
||||||
if current.FixGrammar {
|
|
||||||
selected = append(selected, optFixGrammar)
|
|
||||||
}
|
|
||||||
if current.RemoveFillerWords {
|
|
||||||
selected = append(selected, optRemoveFillerWords)
|
|
||||||
}
|
|
||||||
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewMultiSelect[ppOption]().
|
|
||||||
Title("Post-Processing Options").
|
|
||||||
Description("Select which improvements to apply").
|
|
||||||
Options(options...).
|
|
||||||
Value(&selected),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return current, err
|
|
||||||
}
|
|
||||||
|
|
||||||
result := config.LLMPostProcessingConfig{}
|
|
||||||
for _, opt := range selected {
|
|
||||||
switch opt {
|
|
||||||
case optRemoveStutters:
|
|
||||||
result.RemoveStutters = true
|
|
||||||
case optAddPunctuation:
|
|
||||||
result.AddPunctuation = true
|
|
||||||
case optFixGrammar:
|
|
||||||
result.FixGrammar = true
|
|
||||||
case optRemoveFillerWords:
|
|
||||||
result.RemoveFillerWords = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/config"
|
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/notify"
|
|
||||||
)
|
|
||||||
|
|
||||||
// editNotifications handles the notifications section edit with type and custom messages
|
|
||||||
func editNotifications(cfg *config.Config) error {
|
|
||||||
enabled := cfg.Notifications.Enabled
|
|
||||||
|
|
||||||
desc := "Show notifications for recording status changes"
|
|
||||||
if cfg.Notifications.Enabled {
|
|
||||||
desc = fmt.Sprintf("Currently: enabled (%s). %s", cfg.Notifications.Type, desc)
|
|
||||||
} else {
|
|
||||||
desc = "Currently: disabled. " + desc
|
|
||||||
}
|
|
||||||
|
|
||||||
enableForm := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewConfirm().
|
|
||||||
Title("Enable desktop notifications?").
|
|
||||||
Description(desc).
|
|
||||||
Value(&enabled),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := enableForm.Run(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.Notifications.Enabled = enabled
|
|
||||||
|
|
||||||
if !enabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
notifType := cfg.Notifications.Type
|
|
||||||
if notifType == "" {
|
|
||||||
notifType = "desktop"
|
|
||||||
}
|
|
||||||
|
|
||||||
typeOptions := []huh.Option[string]{
|
|
||||||
huh.NewOption("Desktop notifications (notify-send)", "desktop"),
|
|
||||||
huh.NewOption("Log to console only", "log"),
|
|
||||||
huh.NewOption("None (silent)", "none"),
|
|
||||||
}
|
|
||||||
|
|
||||||
typeForm := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewSelect[string]().
|
|
||||||
Title("Notification Type").
|
|
||||||
Description("How should notifications be displayed?").
|
|
||||||
Options(typeOptions...).
|
|
||||||
Value(¬ifType),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := typeForm.Run(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.Notifications.Type = notifType
|
|
||||||
|
|
||||||
var configureMessages bool
|
|
||||||
msgForm := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewConfirm().
|
|
||||||
Title("Configure custom notification messages?").
|
|
||||||
Description("Customize the text shown in notifications").
|
|
||||||
Affirmative("Yes").
|
|
||||||
Negative("No, use defaults").
|
|
||||||
Value(&configureMessages),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := msgForm.Run(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if configureMessages {
|
|
||||||
if err := editNotificationMessages(cfg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// editNotificationMessages allows editing individual notification messages
|
|
||||||
func editNotificationMessages(cfg *config.Config) error {
|
|
||||||
for {
|
|
||||||
var options []huh.Option[string]
|
|
||||||
for _, def := range notify.MessageDefs {
|
|
||||||
currentBody := def.DefaultBody
|
|
||||||
switch def.ConfigKey {
|
|
||||||
case "recording_started":
|
|
||||||
if cfg.Notifications.Messages.RecordingStarted.Body != "" {
|
|
||||||
currentBody = cfg.Notifications.Messages.RecordingStarted.Body
|
|
||||||
}
|
|
||||||
case "transcribing":
|
|
||||||
if cfg.Notifications.Messages.Transcribing.Body != "" {
|
|
||||||
currentBody = cfg.Notifications.Messages.Transcribing.Body
|
|
||||||
}
|
|
||||||
case "llm_processing":
|
|
||||||
if cfg.Notifications.Messages.LLMProcessing.Body != "" {
|
|
||||||
currentBody = cfg.Notifications.Messages.LLMProcessing.Body
|
|
||||||
}
|
|
||||||
case "config_reloaded":
|
|
||||||
if cfg.Notifications.Messages.ConfigReloaded.Body != "" {
|
|
||||||
currentBody = cfg.Notifications.Messages.ConfigReloaded.Body
|
|
||||||
}
|
|
||||||
case "operation_cancelled":
|
|
||||||
if cfg.Notifications.Messages.OperationCancelled.Body != "" {
|
|
||||||
currentBody = cfg.Notifications.Messages.OperationCancelled.Body
|
|
||||||
}
|
|
||||||
case "recording_aborted":
|
|
||||||
if cfg.Notifications.Messages.RecordingAborted.Body != "" {
|
|
||||||
currentBody = cfg.Notifications.Messages.RecordingAborted.Body
|
|
||||||
}
|
|
||||||
case "injection_aborted":
|
|
||||||
if cfg.Notifications.Messages.InjectionAborted.Body != "" {
|
|
||||||
currentBody = cfg.Notifications.Messages.InjectionAborted.Body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
displayBody := currentBody
|
|
||||||
if len(displayBody) > 30 {
|
|
||||||
displayBody = displayBody[:30] + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
label := fmt.Sprintf("%s: \"%s\"", def.ConfigKey, displayBody)
|
|
||||||
options = append(options, huh.NewOption(label, def.ConfigKey))
|
|
||||||
}
|
|
||||||
options = append(options, huh.NewOption("Back", "back"))
|
|
||||||
|
|
||||||
var selected string
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewSelect[string]().
|
|
||||||
Title("Notification Messages").
|
|
||||||
Description("Select a message to edit").
|
|
||||||
Options(options...).
|
|
||||||
Value(&selected),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if selected == "back" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := editSingleMessage(cfg, selected); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// editSingleMessage edits a single notification message
|
|
||||||
func editSingleMessage(cfg *config.Config, configKey string) error {
|
|
||||||
var def notify.MessageDef
|
|
||||||
for _, d := range notify.MessageDefs {
|
|
||||||
if d.ConfigKey == configKey {
|
|
||||||
def = d
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentTitle, currentBody string
|
|
||||||
switch configKey {
|
|
||||||
case "recording_started":
|
|
||||||
currentTitle = cfg.Notifications.Messages.RecordingStarted.Title
|
|
||||||
currentBody = cfg.Notifications.Messages.RecordingStarted.Body
|
|
||||||
case "transcribing":
|
|
||||||
currentTitle = cfg.Notifications.Messages.Transcribing.Title
|
|
||||||
currentBody = cfg.Notifications.Messages.Transcribing.Body
|
|
||||||
case "llm_processing":
|
|
||||||
currentTitle = cfg.Notifications.Messages.LLMProcessing.Title
|
|
||||||
currentBody = cfg.Notifications.Messages.LLMProcessing.Body
|
|
||||||
case "config_reloaded":
|
|
||||||
currentTitle = cfg.Notifications.Messages.ConfigReloaded.Title
|
|
||||||
currentBody = cfg.Notifications.Messages.ConfigReloaded.Body
|
|
||||||
case "operation_cancelled":
|
|
||||||
currentTitle = cfg.Notifications.Messages.OperationCancelled.Title
|
|
||||||
currentBody = cfg.Notifications.Messages.OperationCancelled.Body
|
|
||||||
case "recording_aborted":
|
|
||||||
currentTitle = cfg.Notifications.Messages.RecordingAborted.Title
|
|
||||||
currentBody = cfg.Notifications.Messages.RecordingAborted.Body
|
|
||||||
case "injection_aborted":
|
|
||||||
currentTitle = cfg.Notifications.Messages.InjectionAborted.Title
|
|
||||||
currentBody = cfg.Notifications.Messages.InjectionAborted.Body
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentTitle == "" {
|
|
||||||
currentTitle = def.DefaultTitle
|
|
||||||
}
|
|
||||||
if currentBody == "" {
|
|
||||||
currentBody = def.DefaultBody
|
|
||||||
}
|
|
||||||
|
|
||||||
title := currentTitle
|
|
||||||
body := currentBody
|
|
||||||
|
|
||||||
var fields []huh.Field
|
|
||||||
if !def.IsError {
|
|
||||||
fields = append(fields, huh.NewInput().
|
|
||||||
Title("Title").
|
|
||||||
Description(fmt.Sprintf("Default: %s", def.DefaultTitle)).
|
|
||||||
Placeholder(def.DefaultTitle).
|
|
||||||
Value(&title))
|
|
||||||
}
|
|
||||||
fields = append(fields, huh.NewInput().
|
|
||||||
Title("Body").
|
|
||||||
Description(fmt.Sprintf("Default: %s", def.DefaultBody)).
|
|
||||||
Placeholder(def.DefaultBody).
|
|
||||||
Value(&body))
|
|
||||||
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(fields...),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
msgConfig := config.MessageConfig{Title: title, Body: body}
|
|
||||||
switch configKey {
|
|
||||||
case "recording_started":
|
|
||||||
cfg.Notifications.Messages.RecordingStarted = msgConfig
|
|
||||||
case "transcribing":
|
|
||||||
cfg.Notifications.Messages.Transcribing = msgConfig
|
|
||||||
case "llm_processing":
|
|
||||||
cfg.Notifications.Messages.LLMProcessing = msgConfig
|
|
||||||
case "config_reloaded":
|
|
||||||
cfg.Notifications.Messages.ConfigReloaded = msgConfig
|
|
||||||
case "operation_cancelled":
|
|
||||||
cfg.Notifications.Messages.OperationCancelled = msgConfig
|
|
||||||
case "recording_aborted":
|
|
||||||
cfg.Notifications.Messages.RecordingAborted = msgConfig
|
|
||||||
case "injection_aborted":
|
|
||||||
cfg.Notifications.Messages.InjectionAborted = msgConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/config"
|
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
|
||||||
)
|
|
||||||
|
|
||||||
// getProviderDisplayName returns the display name for a provider
|
|
||||||
func getProviderDisplayName(providerName string) string {
|
|
||||||
if name, ok := providerDisplayNames[providerName]; ok {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
return providerName
|
|
||||||
}
|
|
||||||
|
|
||||||
// maskAPIKey returns a masked version of an API key for display
|
|
||||||
func maskAPIKey(key string) string {
|
|
||||||
if len(key) <= 8 {
|
|
||||||
return "***"
|
|
||||||
}
|
|
||||||
return key[:7] + "..." + key[len(key)-4:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// getConfiguredProviders returns list of providers with API keys
|
|
||||||
func getConfiguredProviders(cfg *config.Config) []string {
|
|
||||||
var providers []string
|
|
||||||
for name, pc := range cfg.Providers {
|
|
||||||
if pc.APIKey != "" {
|
|
||||||
providers = append(providers, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return providers
|
|
||||||
}
|
|
||||||
|
|
||||||
// editProviders handles the providers section edit with submenu
|
|
||||||
func editProviders(cfg *config.Config, onboarding bool) error {
|
|
||||||
exitLabel := "Done"
|
|
||||||
if onboarding {
|
|
||||||
exitLabel = "Next"
|
|
||||||
}
|
|
||||||
|
|
||||||
// track if we should default to "back" (Next) after configuring a provider
|
|
||||||
defaultToExit := false
|
|
||||||
|
|
||||||
for {
|
|
||||||
var options []huh.Option[string]
|
|
||||||
options = append(options, huh.NewOption("Local", "local"))
|
|
||||||
for _, name := range AllProviders {
|
|
||||||
options = append(options, huh.NewOption(formatProviderOption(cfg, name), name))
|
|
||||||
}
|
|
||||||
options = append(options, huh.NewOption(exitLabel, "back"))
|
|
||||||
|
|
||||||
selected := ""
|
|
||||||
if defaultToExit {
|
|
||||||
selected = "back"
|
|
||||||
}
|
|
||||||
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewSelect[string]().
|
|
||||||
Title("Provider Settings").
|
|
||||||
Description("Select a provider to configure API key").
|
|
||||||
Options(options...).
|
|
||||||
Value(&selected),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if selected == "back" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if selected == "local" {
|
|
||||||
if err := showLocalProviderInfo(); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
apiKey, err := configureSingleProvider(cfg, selected)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if apiKey != "" {
|
|
||||||
if cfg.Providers == nil {
|
|
||||||
cfg.Providers = make(map[string]config.ProviderConfig)
|
|
||||||
}
|
|
||||||
cfg.Providers[selected] = config.ProviderConfig{APIKey: apiKey}
|
|
||||||
defaultToExit = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func showLocalProviderInfo() error {
|
|
||||||
selected := "done"
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewSelect[string]().
|
|
||||||
Title("Local Models").
|
|
||||||
Description("No need to configure any API keys for local models, go to the next step.").
|
|
||||||
Options(huh.NewOption("Done", "done")).
|
|
||||||
Value(&selected),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatProviderOption formats a provider menu option with status
|
|
||||||
func formatProviderOption(cfg *config.Config, name string) string {
|
|
||||||
var status string
|
|
||||||
if pc, exists := cfg.Providers[name]; exists && pc.APIKey != "" {
|
|
||||||
status = "(configured)"
|
|
||||||
} else {
|
|
||||||
status = "(not configured)"
|
|
||||||
}
|
|
||||||
|
|
||||||
switch name {
|
|
||||||
case "openai":
|
|
||||||
return fmt.Sprintf("OpenAI - Whisper + GPT %s", status)
|
|
||||||
case "groq":
|
|
||||||
return fmt.Sprintf("Groq - Whisper + Llama %s", status)
|
|
||||||
case "mistral":
|
|
||||||
return fmt.Sprintf("Mistral - Voxtral %s", status)
|
|
||||||
case "elevenlabs":
|
|
||||||
return fmt.Sprintf("ElevenLabs - Scribe %s", status)
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("%s %s", name, status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// configureSingleProvider handles the complete flow for configuring a single provider's API key.
|
|
||||||
// Shows confirm dialog if key exists, then prompts for new key if needed.
|
|
||||||
// Returns the new API key (empty if user kept current) and any error.
|
|
||||||
func configureSingleProvider(cfg *config.Config, providerName string) (string, error) {
|
|
||||||
var existingKey string
|
|
||||||
if pc, exists := cfg.Providers[providerName]; exists && pc.APIKey != "" {
|
|
||||||
existingKey = pc.APIKey
|
|
||||||
}
|
|
||||||
|
|
||||||
if existingKey != "" {
|
|
||||||
displayName := getProviderDisplayName(providerName)
|
|
||||||
masked := maskAPIKey(existingKey)
|
|
||||||
|
|
||||||
var update bool
|
|
||||||
confirmForm := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewConfirm().
|
|
||||||
Title(fmt.Sprintf("%s API Key", displayName)).
|
|
||||||
Description(fmt.Sprintf("Current: %s", masked)).
|
|
||||||
Affirmative("Update key").
|
|
||||||
Negative("Keep current").
|
|
||||||
Value(&update),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := confirmForm.Run(); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !update {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return inputAPIKey(providerName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func inputAPIKey(providerName string) (string, error) {
|
|
||||||
p := provider.GetProvider(providerName)
|
|
||||||
displayName := getProviderDisplayName(providerName)
|
|
||||||
if p != nil {
|
|
||||||
if name, ok := providerDisplayNames[p.Name()]; ok {
|
|
||||||
displayName = name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var apiKey string
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewInput().
|
|
||||||
Title(fmt.Sprintf("%s API Key", displayName)).
|
|
||||||
Description(fmt.Sprintf("Enter your %s API key", displayName)).
|
|
||||||
EchoMode(huh.EchoModePassword).
|
|
||||||
Value(&apiKey).
|
|
||||||
Validate(func(s string) error {
|
|
||||||
if s == "" {
|
|
||||||
return fmt.Errorf("API key is required")
|
|
||||||
}
|
|
||||||
if p != nil && !p.ValidateAPIKey(s) {
|
|
||||||
return fmt.Errorf("invalid API key format for %s", displayName)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiKey, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureProviderConfigured prompts for API key if provider not configured
|
|
||||||
func ensureProviderConfigured(cfg *config.Config, selectedProvider string, configuredProviders []string) []string {
|
|
||||||
providerName := selectedProvider
|
|
||||||
switch selectedProvider {
|
|
||||||
case "groq-transcription":
|
|
||||||
providerName = "groq"
|
|
||||||
case "mistral-transcription":
|
|
||||||
providerName = "mistral"
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range configuredProviders {
|
|
||||||
if p == providerName {
|
|
||||||
return configuredProviders
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apiKey, err := configureSingleProvider(cfg, providerName)
|
|
||||||
if err != nil || apiKey == "" {
|
|
||||||
return configuredProviders
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.Providers == nil {
|
|
||||||
cfg.Providers = make(map[string]config.ProviderConfig)
|
|
||||||
}
|
|
||||||
cfg.Providers[providerName] = config.ProviderConfig{APIKey: apiKey}
|
|
||||||
|
|
||||||
return append(configuredProviders, providerName)
|
|
||||||
}
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/config"
|
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/deps"
|
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/models/whisper"
|
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
|
||||||
)
|
|
||||||
|
|
||||||
// editTranscription handles the transcription section edit with smart provider detection
|
|
||||||
func editTranscription(cfg *config.Config, configuredProviders []string) ([]string, error) {
|
|
||||||
var transcriptionOptions []huh.Option[string]
|
|
||||||
|
|
||||||
// add local provider first (whisper-cpp)
|
|
||||||
whisperStatus := deps.CheckWhisperCli()
|
|
||||||
if whisperStatus.Installed {
|
|
||||||
transcriptionOptions = append(transcriptionOptions,
|
|
||||||
huh.NewOption("Whisper.cpp (local, no API key)", "whisper-cpp"))
|
|
||||||
} else {
|
|
||||||
transcriptionOptions = append(transcriptionOptions,
|
|
||||||
huh.NewOption("Whisper.cpp (whisper-cli not found)", "whisper-cpp-disabled"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// add configured cloud providers
|
|
||||||
for _, name := range configuredProviders {
|
|
||||||
p := provider.GetProvider(name)
|
|
||||||
if p != nil && len(provider.ModelsOfType(p, provider.Transcription)) > 0 {
|
|
||||||
switch name {
|
|
||||||
case "openai":
|
|
||||||
transcriptionOptions = append(transcriptionOptions,
|
|
||||||
huh.NewOption("OpenAI Whisper", "openai"))
|
|
||||||
case "groq":
|
|
||||||
transcriptionOptions = append(transcriptionOptions,
|
|
||||||
huh.NewOption("Groq Whisper", "groq-transcription"))
|
|
||||||
case "mistral":
|
|
||||||
transcriptionOptions = append(transcriptionOptions,
|
|
||||||
huh.NewOption("Mistral Voxtral", "mistral-transcription"))
|
|
||||||
case "elevenlabs":
|
|
||||||
transcriptionOptions = append(transcriptionOptions,
|
|
||||||
huh.NewOption("ElevenLabs Scribe", "elevenlabs"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
unconfiguredOptions := getUnconfiguredTranscriptionOptions(configuredProviders)
|
|
||||||
if len(unconfiguredOptions) > 0 {
|
|
||||||
transcriptionOptions = append(transcriptionOptions, unconfiguredOptions...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(transcriptionOptions) == 0 {
|
|
||||||
return configuredProviders, fmt.Errorf("no transcription providers available")
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedProvider := cfg.Transcription.Provider
|
|
||||||
if selectedProvider == "" && len(transcriptionOptions) > 0 {
|
|
||||||
selectedProvider = transcriptionOptions[0].Value
|
|
||||||
}
|
|
||||||
|
|
||||||
providerDesc := "Choose which service to use for speech-to-text"
|
|
||||||
if cfg.Transcription.Provider != "" {
|
|
||||||
providerDesc = fmt.Sprintf("Currently: %s/%s", cfg.Transcription.Provider, cfg.Transcription.Model)
|
|
||||||
}
|
|
||||||
|
|
||||||
providerForm := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewSelect[string]().
|
|
||||||
Title("Transcription Provider").
|
|
||||||
Description(providerDesc).
|
|
||||||
Options(transcriptionOptions...).
|
|
||||||
Value(&selectedProvider),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := providerForm.Run(); err != nil {
|
|
||||||
return configuredProviders, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// handle disabled whisper-cpp selection
|
|
||||||
if selectedProvider == "whisper-cpp-disabled" {
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println(StyleWarning.Render("whisper-cli not found in PATH"))
|
|
||||||
fmt.Println(StyleMuted.Render("Install whisper.cpp to use local transcription:"))
|
|
||||||
fmt.Println(StyleMuted.Render(" https://github.com/ggerganov/whisper.cpp"))
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
var proceed bool
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewConfirm().
|
|
||||||
Title("Continue?").
|
|
||||||
Affirmative("Choose another provider").
|
|
||||||
Negative("Cancel").
|
|
||||||
Value(&proceed),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return configuredProviders, err
|
|
||||||
}
|
|
||||||
if proceed {
|
|
||||||
return editTranscription(cfg, configuredProviders)
|
|
||||||
}
|
|
||||||
return configuredProviders, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// local providers don't need API key configuration
|
|
||||||
if selectedProvider != "whisper-cpp" {
|
|
||||||
configuredProviders = ensureProviderConfigured(cfg, selectedProvider, configuredProviders)
|
|
||||||
}
|
|
||||||
cfg.Transcription.Provider = selectedProvider
|
|
||||||
|
|
||||||
modelOptions := getTranscriptionModelOptions(selectedProvider)
|
|
||||||
selectedModel := cfg.Transcription.Model
|
|
||||||
if selectedModel == "" && len(modelOptions) > 0 {
|
|
||||||
// skip header options (empty value) to find first real model
|
|
||||||
for _, opt := range modelOptions {
|
|
||||||
if opt.Value != "" {
|
|
||||||
selectedModel = opt.Value
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
modelDesc := ""
|
|
||||||
if cfg.Transcription.Model != "" {
|
|
||||||
modelDesc = fmt.Sprintf("Currently: %s", cfg.Transcription.Model)
|
|
||||||
}
|
|
||||||
|
|
||||||
modelForm := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewSelect[string]().
|
|
||||||
Title("Transcription Model").
|
|
||||||
Description(modelDesc).
|
|
||||||
Options(modelOptions...).
|
|
||||||
Value(&selectedModel),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := modelForm.Run(); err != nil {
|
|
||||||
return configuredProviders, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// if user selected a section header (empty value), re-prompt
|
|
||||||
if selectedModel == "" {
|
|
||||||
return editTranscription(cfg, configuredProviders)
|
|
||||||
}
|
|
||||||
|
|
||||||
registryName := mapConfigProviderToRegistry(selectedProvider)
|
|
||||||
|
|
||||||
// for whisper-cpp, check if model needs download
|
|
||||||
if selectedProvider == "whisper-cpp" && !whisper.IsInstalled(selectedModel) {
|
|
||||||
modelInfo := whisper.GetModel(selectedModel)
|
|
||||||
if modelInfo == nil {
|
|
||||||
return configuredProviders, fmt.Errorf("unknown model: %s", selectedModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
var confirm bool
|
|
||||||
confirmForm := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewConfirm().
|
|
||||||
Title(fmt.Sprintf("Download %s (%s)?", modelInfo.Name, modelInfo.Size)).
|
|
||||||
Description("Model is not installed. Download now?").
|
|
||||||
Affirmative("Download").
|
|
||||||
Negative("Cancel").
|
|
||||||
Value(&confirm),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := confirmForm.Run(); err != nil {
|
|
||||||
return configuredProviders, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !confirm {
|
|
||||||
return configuredProviders, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// download with progress
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Printf("Downloading %s...\n", modelInfo.Name)
|
|
||||||
|
|
||||||
lastPct := 0
|
|
||||||
err := whisper.Download(context.Background(), selectedModel, func(downloaded, total int64) {
|
|
||||||
if total > 0 {
|
|
||||||
pct := int(downloaded * 100 / total)
|
|
||||||
if pct >= lastPct+10 {
|
|
||||||
fmt.Printf(" %d%%\n", pct)
|
|
||||||
lastPct = pct
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(StyleError.Render(fmt.Sprintf("Download failed: %v", err)))
|
|
||||||
return configuredProviders, err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(StyleSuccess.Render(fmt.Sprintf("Downloaded %s", modelInfo.Name)))
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.Transcription.Model = selectedModel
|
|
||||||
|
|
||||||
// select language for this model
|
|
||||||
model, err := provider.GetModel(registryName, selectedModel)
|
|
||||||
if err != nil {
|
|
||||||
return configuredProviders, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.Transcription.Language != "" && !model.SupportsLanguage(cfg.Transcription.Language) {
|
|
||||||
cfg.Transcription.Language = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(model.SupportedLanguages) <= 1 {
|
|
||||||
if len(model.SupportedLanguages) == 1 {
|
|
||||||
cfg.Transcription.Language = model.SupportedLanguages[0]
|
|
||||||
} else {
|
|
||||||
cfg.Transcription.Language = ""
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
languageOptions := getModelLanguageOptions(model, cfg.Transcription.Language)
|
|
||||||
selectedLanguage := cfg.Transcription.Language
|
|
||||||
|
|
||||||
languageForm := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewSelect[string]().
|
|
||||||
Title("Language").
|
|
||||||
Description("Select language for transcription").
|
|
||||||
Options(languageOptions...).
|
|
||||||
Filtering(true).
|
|
||||||
Value(&selectedLanguage),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := languageForm.Run(); err != nil {
|
|
||||||
return configuredProviders, err
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.Transcription.Language = selectedLanguage
|
|
||||||
}
|
|
||||||
|
|
||||||
// set streaming mode based on model capabilities
|
|
||||||
if model.SupportsBothModes() {
|
|
||||||
useStreaming := cfg.Transcription.Streaming
|
|
||||||
streamingForm := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewConfirm().
|
|
||||||
Title("Enable streaming mode?").
|
|
||||||
Description("This model supports both batch and streaming modes").
|
|
||||||
Affirmative("Yes, use streaming (real-time)").
|
|
||||||
Negative("No, use batch (after recording)").
|
|
||||||
Value(&useStreaming),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := streamingForm.Run(); err != nil {
|
|
||||||
return configuredProviders, err
|
|
||||||
}
|
|
||||||
cfg.Transcription.Streaming = useStreaming
|
|
||||||
} else if model.SupportsStreaming {
|
|
||||||
cfg.Transcription.Streaming = true
|
|
||||||
fmt.Println(StyleSuccess.Render("Streaming mode enabled (this model only supports streaming)"))
|
|
||||||
} else {
|
|
||||||
cfg.Transcription.Streaming = false
|
|
||||||
}
|
|
||||||
|
|
||||||
return configuredProviders, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getUnconfiguredTranscriptionOptions returns options for providers not yet configured
|
|
||||||
func getUnconfiguredTranscriptionOptions(configuredProviders []string) []huh.Option[string] {
|
|
||||||
configured := make(map[string]bool)
|
|
||||||
for _, p := range configuredProviders {
|
|
||||||
configured[p] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var options []huh.Option[string]
|
|
||||||
if !configured["openai"] {
|
|
||||||
options = append(options, huh.NewOption("OpenAI Whisper (not configured)", "openai"))
|
|
||||||
}
|
|
||||||
if !configured["groq"] {
|
|
||||||
options = append(options,
|
|
||||||
huh.NewOption("Groq Whisper (not configured)", "groq-transcription"))
|
|
||||||
}
|
|
||||||
if !configured["mistral"] {
|
|
||||||
options = append(options, huh.NewOption("Mistral Voxtral (not configured)", "mistral-transcription"))
|
|
||||||
}
|
|
||||||
if !configured["elevenlabs"] {
|
|
||||||
options = append(options, huh.NewOption("ElevenLabs Scribe (not configured)", "elevenlabs"))
|
|
||||||
}
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
func getTranscriptionModelOptions(configProvider string) []huh.Option[string] {
|
|
||||||
// map config provider name to registry provider name
|
|
||||||
registryName := mapConfigProviderToRegistry(configProvider)
|
|
||||||
p := provider.GetProvider(registryName)
|
|
||||||
if p == nil {
|
|
||||||
return []huh.Option[string]{}
|
|
||||||
}
|
|
||||||
|
|
||||||
models := provider.ModelsOfType(p, provider.Transcription)
|
|
||||||
|
|
||||||
var options []huh.Option[string]
|
|
||||||
for _, m := range models {
|
|
||||||
label := buildModelLabel(m)
|
|
||||||
if m.Local && registryName == "whisper-cpp" {
|
|
||||||
if whisper.IsInstalled(m.ID) {
|
|
||||||
label = "[x] " + label
|
|
||||||
} else {
|
|
||||||
label = "[ ] " + label
|
|
||||||
}
|
|
||||||
}
|
|
||||||
options = append(options, huh.NewOption(label, m.ID))
|
|
||||||
}
|
|
||||||
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
// mapConfigProviderToRegistry maps config provider names to registry provider names
|
|
||||||
func mapConfigProviderToRegistry(configProvider string) string {
|
|
||||||
switch configProvider {
|
|
||||||
case "groq-transcription":
|
|
||||||
return "groq"
|
|
||||||
case "mistral-transcription":
|
|
||||||
return "mistral"
|
|
||||||
default:
|
|
||||||
return configProvider
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildModelLabel creates the display label for a model option
|
|
||||||
func buildModelLabel(m provider.Model) string {
|
|
||||||
label := fmt.Sprintf("%s (%s)", m.Name, m.Description)
|
|
||||||
|
|
||||||
// append size for local models
|
|
||||||
if m.Local && m.LocalInfo != nil {
|
|
||||||
label += fmt.Sprintf(" [%s]", m.LocalInfo.Size)
|
|
||||||
}
|
|
||||||
|
|
||||||
// append mode capabilities
|
|
||||||
if m.SupportsBothModes() {
|
|
||||||
label += " [batch+streaming]"
|
|
||||||
} else if m.SupportsStreaming {
|
|
||||||
label += " [streaming]"
|
|
||||||
}
|
|
||||||
|
|
||||||
return label
|
|
||||||
}
|
|
||||||
@@ -18,20 +18,20 @@ func TestGetTranscriptionModelOptions_ShowsCapabilities(t *testing.T) {
|
|||||||
|
|
||||||
// verify models show capability tags
|
// verify models show capability tags
|
||||||
for _, opt := range options {
|
for _, opt := range options {
|
||||||
model, _, _ := provider.FindModelByID(opt.Value)
|
model, _, _ := provider.FindModelByID(opt.ID)
|
||||||
if model == nil {
|
if model == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if model.SupportsStreaming && !model.SupportsBatch {
|
if model.SupportsStreaming && !model.SupportsBatch {
|
||||||
// streaming-only should have [streaming] tag
|
// streaming-only should have [streaming] tag
|
||||||
if !strings.Contains(opt.Key, "[streaming]") {
|
if !strings.Contains(opt.Label, "[streaming]") {
|
||||||
t.Errorf("streaming-only model %s should have [streaming] tag in label: %s", opt.Value, opt.Key)
|
t.Errorf("streaming-only model %s should have [streaming] tag in label: %s", opt.ID, opt.Label)
|
||||||
}
|
}
|
||||||
} else if model.SupportsBothModes() {
|
} else if model.SupportsBothModes() {
|
||||||
// both modes should have [batch+streaming] tag
|
// both modes should have [batch+streaming] tag
|
||||||
if !strings.Contains(opt.Key, "[batch+streaming]") {
|
if !strings.Contains(opt.Label, "[batch+streaming]") {
|
||||||
t.Errorf("both-modes model %s should have [batch+streaming] tag in label: %s", opt.Value, opt.Key)
|
t.Errorf("both-modes model %s should have [batch+streaming] tag in label: %s", opt.ID, opt.Label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// batch-only models don't need a tag
|
// batch-only models don't need a tag
|
||||||
@@ -43,8 +43,8 @@ func TestGetTranscriptionModelOptions_NoHeadersAnymore(t *testing.T) {
|
|||||||
options := getTranscriptionModelOptions("elevenlabs")
|
options := getTranscriptionModelOptions("elevenlabs")
|
||||||
|
|
||||||
for _, opt := range options {
|
for _, opt := range options {
|
||||||
if opt.Value == "" {
|
if opt.ID == "" {
|
||||||
t.Errorf("should not have headers anymore, got: %s", opt.Key)
|
t.Errorf("should not have headers anymore, got: %s", opt.Label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,9 +59,9 @@ func TestGetTranscriptionModelOptions_OpenAI_ShowsCapabilities(t *testing.T) {
|
|||||||
|
|
||||||
// gpt-4o-transcribe and gpt-4o-mini-transcribe should have [batch+streaming]
|
// gpt-4o-transcribe and gpt-4o-mini-transcribe should have [batch+streaming]
|
||||||
for _, opt := range options {
|
for _, opt := range options {
|
||||||
if strings.Contains(opt.Value, "gpt-4o") {
|
if strings.Contains(opt.ID, "gpt-4o") {
|
||||||
if !strings.Contains(opt.Key, "[batch+streaming]") {
|
if !strings.Contains(opt.Label, "[batch+streaming]") {
|
||||||
t.Errorf("gpt-4o model %s should have [batch+streaming] tag: %s", opt.Value, opt.Key)
|
t.Errorf("gpt-4o model %s should have [batch+streaming] tag: %s", opt.ID, opt.Label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,8 +76,8 @@ func TestGetTranscriptionModelOptions_Deepgram_ShowsBothModes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, opt := range options {
|
for _, opt := range options {
|
||||||
if !strings.Contains(opt.Key, "[batch+streaming]") {
|
if !strings.Contains(opt.Label, "[batch+streaming]") {
|
||||||
t.Errorf("deepgram model %s should have [batch+streaming] tag: %s", opt.Value, opt.Key)
|
t.Errorf("deepgram model %s should have [batch+streaming] tag: %s", opt.ID, opt.Label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,8 +93,8 @@ func TestGetTranscriptionModelOptions_Groq_BatchOnly(t *testing.T) {
|
|||||||
|
|
||||||
// batch-only models should not have any mode tags
|
// batch-only models should not have any mode tags
|
||||||
for _, opt := range options {
|
for _, opt := range options {
|
||||||
if strings.Contains(opt.Key, "[streaming]") || strings.Contains(opt.Key, "[batch]") {
|
if strings.Contains(opt.Label, "[streaming]") || strings.Contains(opt.Label, "[batch]") {
|
||||||
t.Errorf("batch-only model should not have mode tags: %s", opt.Key)
|
t.Errorf("batch-only model should not have mode tags: %s", opt.Label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// runFreshInstall runs the guided onboarding flow for fresh installs
|
|
||||||
// Uses the same screens as the menu for consistency
|
|
||||||
func runFreshInstall(cfg *config.Config) (*ConfigureResult, error) {
|
|
||||||
fmt.Println(Logo())
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println(StyleMuted.Render("Voice-powered typing for Wayland/Hyprland"))
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// 1. Providers - same screen as menu
|
|
||||||
if err := editProviders(cfg, true); err != nil {
|
|
||||||
return &ConfigureResult{Cancelled: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
configuredProviders := getConfiguredProviders(cfg)
|
|
||||||
if len(configuredProviders) == 0 {
|
|
||||||
return &ConfigureResult{Cancelled: true}, fmt.Errorf("no providers configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Transcription - same screen as menu
|
|
||||||
var err error
|
|
||||||
configuredProviders, err = editTranscription(cfg, configuredProviders)
|
|
||||||
if err != nil {
|
|
||||||
return &ConfigureResult{Cancelled: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. LLM - same screen as menu
|
|
||||||
configuredProviders, err = editLLM(cfg, configuredProviders)
|
|
||||||
if err != nil {
|
|
||||||
return &ConfigureResult{Cancelled: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Keywords
|
|
||||||
keywords, err := inputKeywords(cfg.Keywords)
|
|
||||||
if err != nil {
|
|
||||||
return &ConfigureResult{Cancelled: true}, nil
|
|
||||||
}
|
|
||||||
cfg.Keywords = keywords
|
|
||||||
|
|
||||||
// 6. Injection backends
|
|
||||||
backends, err := selectBackends(cfg.Injection.Backends)
|
|
||||||
if err != nil {
|
|
||||||
return &ConfigureResult{Cancelled: true}, nil
|
|
||||||
}
|
|
||||||
cfg.Injection.Backends = backends
|
|
||||||
|
|
||||||
// 7. Notifications - same screen as menu
|
|
||||||
if err := editNotifications(cfg); err != nil {
|
|
||||||
return &ConfigureResult{Cancelled: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. Advanced settings prompt
|
|
||||||
wantAdvanced, err := askAdvancedSettings()
|
|
||||||
if err != nil {
|
|
||||||
return &ConfigureResult{Cancelled: true}, nil
|
|
||||||
}
|
|
||||||
if wantAdvanced {
|
|
||||||
if err := editAdvanced(cfg, true); err != nil {
|
|
||||||
return &ConfigureResult{Cancelled: true}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ConfigureResult{Config: cfg, Cancelled: false}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func inputKeywords(existingKeywords []string) ([]string, error) {
|
|
||||||
var keywordsInput string
|
|
||||||
if len(existingKeywords) > 0 {
|
|
||||||
keywordsInput = strings.Join(existingKeywords, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Keywords").
|
|
||||||
Description("Comma-separated words to help with spelling (names, technical terms, etc.)").
|
|
||||||
Placeholder("e.g., Kubernetes, PostgreSQL, John Smith").
|
|
||||||
Value(&keywordsInput),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if keywordsInput == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(keywordsInput, ",")
|
|
||||||
keywords := make([]string, 0, len(parts))
|
|
||||||
for _, p := range parts {
|
|
||||||
p = strings.TrimSpace(p)
|
|
||||||
if p != "" {
|
|
||||||
keywords = append(keywords, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return keywords, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectBackends(existingBackends []string) ([]string, error) {
|
|
||||||
options := []huh.Option[string]{
|
|
||||||
huh.NewOption("ydotool - Best for Chromium/Electron (needs ydotoold)", "ydotool"),
|
|
||||||
huh.NewOption("wtype - Native Wayland typing", "wtype"),
|
|
||||||
huh.NewOption("clipboard - Copy to clipboard only", "clipboard"),
|
|
||||||
}
|
|
||||||
|
|
||||||
var selected []string
|
|
||||||
if len(existingBackends) > 0 {
|
|
||||||
selected = existingBackends
|
|
||||||
} else {
|
|
||||||
selected = []string{"ydotool", "wtype", "clipboard"}
|
|
||||||
}
|
|
||||||
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewMultiSelect[string]().
|
|
||||||
Title("Text Injection Backends").
|
|
||||||
Description("Backends are tried in order until one succeeds (fallback chain)").
|
|
||||||
Options(options...).
|
|
||||||
Value(&selected),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(selected) == 0 {
|
|
||||||
return nil, fmt.Errorf("at least one backend required")
|
|
||||||
}
|
|
||||||
|
|
||||||
return selected, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func askAdvancedSettings() (bool, error) {
|
|
||||||
var want bool
|
|
||||||
form := huh.NewForm(
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewConfirm().
|
|
||||||
Title("Configure advanced settings?").
|
|
||||||
Description("Recording parameters, injection timeouts, etc.").
|
|
||||||
Value(&want),
|
|
||||||
),
|
|
||||||
).WithTheme(getTheme())
|
|
||||||
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return want, nil
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,123 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/leonardotrapani/hyprvoice/internal/config"
|
||||||
|
"github.com/leonardotrapani/hyprvoice/internal/models/whisper"
|
||||||
|
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AllProviders is the list of all supported cloud providers (require API keys).
|
||||||
|
var AllProviders = []string{"openai", "groq", "mistral", "elevenlabs", "deepgram"}
|
||||||
|
|
||||||
|
// LocalProviders is the list of local providers (no API key required).
|
||||||
|
var LocalProviders = []string{"whisper-cpp"}
|
||||||
|
|
||||||
|
// providerDisplayNames maps provider IDs to human-readable names.
|
||||||
|
var providerDisplayNames = map[string]string{
|
||||||
|
"openai": "OpenAI",
|
||||||
|
"groq": "Groq",
|
||||||
|
"mistral": "Mistral",
|
||||||
|
"elevenlabs": "ElevenLabs",
|
||||||
|
"deepgram": "Deepgram",
|
||||||
|
"whisper-cpp": "Whisper.cpp (local)",
|
||||||
|
}
|
||||||
|
|
||||||
|
func getProviderDisplayName(providerName string) string {
|
||||||
|
if name, ok := providerDisplayNames[providerName]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return providerName
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskAPIKey(key string) string {
|
||||||
|
if len(key) <= 8 {
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
return key[:7] + "..." + key[len(key)-4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasUserChanges(cfg *config.Config) bool {
|
||||||
|
if len(cfg.Providers) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if cfg.Transcription.APIKey != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func getConfiguredProviders(cfg *config.Config) []string {
|
||||||
|
providers := make([]string, 0, len(cfg.Providers))
|
||||||
|
for name, pc := range cfg.Providers {
|
||||||
|
if pc.APIKey != "" {
|
||||||
|
providers = append(providers, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(providers)
|
||||||
|
return providers
|
||||||
|
}
|
||||||
|
|
||||||
|
func isProviderConfigured(cfg *config.Config, providerName string) bool {
|
||||||
|
if pc, ok := cfg.Providers[providerName]; ok {
|
||||||
|
return pc.APIKey != ""
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapConfigProviderToRegistry(configProvider string) string {
|
||||||
|
switch configProvider {
|
||||||
|
case "groq-transcription", "groq-translation":
|
||||||
|
return "groq"
|
||||||
|
case "mistral-transcription":
|
||||||
|
return "mistral"
|
||||||
|
default:
|
||||||
|
return configProvider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildModelLabel(m provider.Model) string {
|
||||||
|
label := fmt.Sprintf("%s (%s)", m.Name, m.Description)
|
||||||
|
|
||||||
|
if m.Local && m.LocalInfo != nil {
|
||||||
|
label += fmt.Sprintf(" [%s]", m.LocalInfo.Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.SupportsBothModes() {
|
||||||
|
label += " [batch+streaming]"
|
||||||
|
} else if m.SupportsStreaming {
|
||||||
|
label += " [streaming]"
|
||||||
|
}
|
||||||
|
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTranscriptionModelOptions(configProvider string) []modelOption {
|
||||||
|
if configProvider == "groq-translation" {
|
||||||
|
return []modelOption{{ID: "whisper-large-v3", Label: "whisper-large-v3 (only option)"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
registryName := mapConfigProviderToRegistry(configProvider)
|
||||||
|
p := provider.GetProvider(registryName)
|
||||||
|
if p == nil {
|
||||||
|
return []modelOption{}
|
||||||
|
}
|
||||||
|
|
||||||
|
models := provider.ModelsOfType(p, provider.Transcription)
|
||||||
|
options := make([]modelOption, 0, len(models))
|
||||||
|
for _, m := range models {
|
||||||
|
label := buildModelLabel(m)
|
||||||
|
if m.Local && registryName == "whisper-cpp" {
|
||||||
|
if whisper.IsInstalled(m.ID) {
|
||||||
|
label = "[x] " + label
|
||||||
|
} else {
|
||||||
|
label = "[ ] " + label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options = append(options, modelOption{ID: m.ID, Label: label})
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
|
||||||
)
|
|
||||||
|
|
||||||
// getModelLanguageOptions returns language options supported by the given model
|
|
||||||
func getModelLanguageOptions(model *provider.Model, currentLang string) []huh.Option[string] {
|
|
||||||
var options []huh.Option[string]
|
|
||||||
|
|
||||||
// auto-detect is always first
|
|
||||||
autoLabel := "Auto-detect (recommended)"
|
|
||||||
if currentLang == "" {
|
|
||||||
autoLabel += " (current)"
|
|
||||||
}
|
|
||||||
options = append(options, huh.NewOption(autoLabel, ""))
|
|
||||||
|
|
||||||
if model == nil {
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, code := range model.SupportedLanguages {
|
|
||||||
label := code
|
|
||||||
if code == currentLang {
|
|
||||||
label += " (current)"
|
|
||||||
}
|
|
||||||
options = append(options, huh.NewOption(label, code))
|
|
||||||
}
|
|
||||||
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/list"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type listScreen struct {
|
||||||
|
state *wizardState
|
||||||
|
title string
|
||||||
|
desc []string
|
||||||
|
list list.Model
|
||||||
|
footer string
|
||||||
|
errText string
|
||||||
|
onPick func(optionItem) screen
|
||||||
|
onBack func() screen
|
||||||
|
}
|
||||||
|
|
||||||
|
func newListScreen(state *wizardState, title string, desc []string, items []optionItem, onPick func(optionItem) screen, onBack func() screen) *listScreen {
|
||||||
|
delegate := list.NewDefaultDelegate()
|
||||||
|
l := list.New(itemsToList(items), delegate, 0, 0)
|
||||||
|
l.DisableQuitKeybindings()
|
||||||
|
l.SetShowHelp(false)
|
||||||
|
l.SetFilteringEnabled(true)
|
||||||
|
l.SetShowStatusBar(false)
|
||||||
|
l.Title = title
|
||||||
|
return &listScreen{
|
||||||
|
state: state,
|
||||||
|
title: title,
|
||||||
|
desc: desc,
|
||||||
|
list: l,
|
||||||
|
footer: "enter select • esc back • / filter",
|
||||||
|
onPick: onPick,
|
||||||
|
onBack: onBack,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *listScreen) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *listScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
s.list.SetSize(msg.Width-4, msg.Height-8)
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "enter":
|
||||||
|
if s.list.FilterState() != list.Filtering {
|
||||||
|
if item, ok := s.list.SelectedItem().(optionItem); ok {
|
||||||
|
if item.disabled {
|
||||||
|
s.errText = "That option isn't available in this environment."
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if s.onPick != nil {
|
||||||
|
return s.onPick(item), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "esc", "q":
|
||||||
|
if s.list.FilterState() == list.Unfiltered {
|
||||||
|
if s.onBack != nil {
|
||||||
|
return s.onBack(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
s.list, cmd = s.list.Update(msg)
|
||||||
|
return s, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *listScreen) View() string {
|
||||||
|
header := renderHeader(s.title, s.desc, s.errText)
|
||||||
|
footer := renderFooter(s.footer, s.list.FilterState() != list.Unfiltered)
|
||||||
|
return header + s.list.View() + "\n" + footer
|
||||||
|
}
|
||||||
|
|
||||||
|
type confirmScreen struct {
|
||||||
|
state *wizardState
|
||||||
|
title string
|
||||||
|
desc []string
|
||||||
|
list list.Model
|
||||||
|
footer string
|
||||||
|
onYes func() screen
|
||||||
|
onNo func() screen
|
||||||
|
errText string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConfirmScreen(state *wizardState, title string, desc []string, yesLabel, noLabel string, onYes func() screen, onNo func() screen) *confirmScreen {
|
||||||
|
items := []optionItem{
|
||||||
|
{title: yesLabel, value: "yes"},
|
||||||
|
{title: noLabel, value: "no"},
|
||||||
|
}
|
||||||
|
delegate := list.NewDefaultDelegate()
|
||||||
|
l := list.New(itemsToList(items), delegate, 0, 0)
|
||||||
|
l.DisableQuitKeybindings()
|
||||||
|
l.SetShowHelp(false)
|
||||||
|
l.SetFilteringEnabled(false)
|
||||||
|
l.SetShowStatusBar(false)
|
||||||
|
l.Title = title
|
||||||
|
return &confirmScreen{state: state, title: title, desc: desc, list: l, footer: "enter select • esc back", onYes: onYes, onNo: onNo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *confirmScreen) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (s *confirmScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
s.list.SetSize(msg.Width-4, msg.Height-8)
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "enter":
|
||||||
|
if item, ok := s.list.SelectedItem().(optionItem); ok {
|
||||||
|
if item.value == "yes" && s.onYes != nil {
|
||||||
|
return s.onYes(), nil
|
||||||
|
}
|
||||||
|
if item.value == "no" && s.onNo != nil {
|
||||||
|
return s.onNo(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "esc", "q":
|
||||||
|
if s.onNo != nil {
|
||||||
|
return s.onNo(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
s.list, cmd = s.list.Update(msg)
|
||||||
|
return s, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *confirmScreen) View() string {
|
||||||
|
header := renderHeader(s.title, s.desc, s.errText)
|
||||||
|
footer := renderFooter(s.footer, false)
|
||||||
|
return header + s.list.View() + "\n" + footer
|
||||||
|
}
|
||||||
|
|
||||||
|
type inputScreen struct {
|
||||||
|
state *wizardState
|
||||||
|
title string
|
||||||
|
desc []string
|
||||||
|
input textinput.Model
|
||||||
|
footer string
|
||||||
|
errText string
|
||||||
|
onSubmit func(string) screen
|
||||||
|
onCancel func() screen
|
||||||
|
validateFn func(string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInputScreen(state *wizardState, title string, desc []string, value string, placeholder string, password bool, validateFn func(string) error, onSubmit func(string) screen, onCancel func() screen) *inputScreen {
|
||||||
|
input := textinput.New()
|
||||||
|
input.SetValue(value)
|
||||||
|
input.Placeholder = placeholder
|
||||||
|
if password {
|
||||||
|
input.EchoMode = textinput.EchoPassword
|
||||||
|
input.EchoCharacter = '*'
|
||||||
|
}
|
||||||
|
input.Focus()
|
||||||
|
input.CharLimit = 0
|
||||||
|
return &inputScreen{
|
||||||
|
state: state,
|
||||||
|
title: title,
|
||||||
|
desc: desc,
|
||||||
|
input: input,
|
||||||
|
footer: "enter save • esc back",
|
||||||
|
onSubmit: onSubmit,
|
||||||
|
onCancel: onCancel,
|
||||||
|
validateFn: validateFn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *inputScreen) Init() tea.Cmd { return textinput.Blink }
|
||||||
|
|
||||||
|
func (s *inputScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "enter":
|
||||||
|
value := strings.TrimSpace(s.input.Value())
|
||||||
|
if s.validateFn != nil {
|
||||||
|
if err := s.validateFn(value); err != nil {
|
||||||
|
s.errText = err.Error()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.onSubmit != nil {
|
||||||
|
return s.onSubmit(value), nil
|
||||||
|
}
|
||||||
|
case "esc", "q":
|
||||||
|
if s.onCancel != nil {
|
||||||
|
return s.onCancel(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
s.input, cmd = s.input.Update(msg)
|
||||||
|
return s, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *inputScreen) View() string {
|
||||||
|
header := renderHeader(s.title, s.desc, s.errText)
|
||||||
|
footer := renderFooter(s.footer, false)
|
||||||
|
return header + s.input.View() + "\n\n" + footer
|
||||||
|
}
|
||||||
|
|
||||||
|
type multiSelectScreen struct {
|
||||||
|
state *wizardState
|
||||||
|
title string
|
||||||
|
desc []string
|
||||||
|
list list.Model
|
||||||
|
footer string
|
||||||
|
errText string
|
||||||
|
onSubmit func([]toggleItem) screen
|
||||||
|
onCancel func() screen
|
||||||
|
requireOne bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMultiSelectScreen(state *wizardState, title string, desc []string, items []toggleItem, requireOne bool, onSubmit func([]toggleItem) screen, onCancel func() screen) *multiSelectScreen {
|
||||||
|
delegate := list.NewDefaultDelegate()
|
||||||
|
l := list.New(toggleItemsToList(items), delegate, 0, 0)
|
||||||
|
l.DisableQuitKeybindings()
|
||||||
|
l.SetShowHelp(false)
|
||||||
|
l.SetFilteringEnabled(true)
|
||||||
|
l.SetShowStatusBar(false)
|
||||||
|
l.Title = title
|
||||||
|
return &multiSelectScreen{
|
||||||
|
state: state,
|
||||||
|
title: title,
|
||||||
|
desc: desc,
|
||||||
|
list: l,
|
||||||
|
footer: "space toggle • enter save • esc back • / filter",
|
||||||
|
onSubmit: onSubmit,
|
||||||
|
onCancel: onCancel,
|
||||||
|
requireOne: requireOne,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *multiSelectScreen) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (s *multiSelectScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
s.list.SetSize(msg.Width-4, msg.Height-8)
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case " ":
|
||||||
|
idx := s.list.Index()
|
||||||
|
items := s.list.Items()
|
||||||
|
if idx >= 0 && idx < len(items) {
|
||||||
|
if item, ok := items[idx].(toggleItem); ok {
|
||||||
|
item.selected = !item.selected
|
||||||
|
items[idx] = item
|
||||||
|
s.list.SetItems(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "enter":
|
||||||
|
items := listToToggleItems(s.list.Items())
|
||||||
|
if s.requireOne {
|
||||||
|
has := false
|
||||||
|
for _, item := range items {
|
||||||
|
if item.selected {
|
||||||
|
has = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
s.errText = "Select at least one option to continue."
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.onSubmit != nil {
|
||||||
|
return s.onSubmit(items), nil
|
||||||
|
}
|
||||||
|
case "esc", "q":
|
||||||
|
if s.list.FilterState() == list.Unfiltered {
|
||||||
|
if s.onCancel != nil {
|
||||||
|
return s.onCancel(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
s.list, cmd = s.list.Update(msg)
|
||||||
|
return s, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *multiSelectScreen) View() string {
|
||||||
|
header := renderHeader(s.title, s.desc, s.errText)
|
||||||
|
footer := renderFooter(s.footer, s.list.FilterState() != list.Unfiltered)
|
||||||
|
return header + s.list.View() + "\n" + footer
|
||||||
|
}
|
||||||
|
|
||||||
|
type infoScreen struct {
|
||||||
|
state *wizardState
|
||||||
|
title string
|
||||||
|
desc []string
|
||||||
|
footer string
|
||||||
|
next func() screen
|
||||||
|
back func() screen
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInfoScreen(state *wizardState, title string, desc []string, next func() screen, back func() screen) *infoScreen {
|
||||||
|
return &infoScreen{state: state, title: title, desc: desc, footer: "enter continue • esc back", next: next, back: back}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *infoScreen) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (s *infoScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "enter":
|
||||||
|
if s.next != nil {
|
||||||
|
return s.next(), nil
|
||||||
|
}
|
||||||
|
case "esc", "q":
|
||||||
|
if s.back != nil {
|
||||||
|
return s.back(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *infoScreen) View() string {
|
||||||
|
header := renderHeader(s.title, s.desc, "")
|
||||||
|
footer := renderFooter(s.footer, false)
|
||||||
|
return header + "\n" + footer
|
||||||
|
}
|
||||||
|
|
||||||
|
type formField struct {
|
||||||
|
key string
|
||||||
|
label string
|
||||||
|
desc string
|
||||||
|
input textinput.Model
|
||||||
|
validate func(string) error
|
||||||
|
required bool
|
||||||
|
sensitive bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type formScreen struct {
|
||||||
|
state *wizardState
|
||||||
|
title string
|
||||||
|
desc []string
|
||||||
|
fields []formField
|
||||||
|
focused int
|
||||||
|
footer string
|
||||||
|
errText string
|
||||||
|
onSubmit func(map[string]string) screen
|
||||||
|
onCancel func() screen
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFormScreen(state *wizardState, title string, desc []string, fields []formField, onSubmit func(map[string]string) screen, onCancel func() screen) *formScreen {
|
||||||
|
if len(fields) > 0 {
|
||||||
|
fields[0].input.Focus()
|
||||||
|
}
|
||||||
|
return &formScreen{state: state, title: title, desc: desc, fields: fields, onSubmit: onSubmit, onCancel: onCancel}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *formScreen) Init() tea.Cmd { return textinput.Blink }
|
||||||
|
|
||||||
|
func (s *formScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "q":
|
||||||
|
if s.onCancel != nil {
|
||||||
|
return s.onCancel(), nil
|
||||||
|
}
|
||||||
|
case "tab", "down":
|
||||||
|
s.moveFocus(1)
|
||||||
|
case "shift+tab", "up":
|
||||||
|
s.moveFocus(-1)
|
||||||
|
case "enter":
|
||||||
|
if s.focused == len(s.fields)-1 {
|
||||||
|
values, err := s.validateAll()
|
||||||
|
if err != nil {
|
||||||
|
s.errText = err.Error()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if s.onSubmit != nil {
|
||||||
|
return s.onSubmit(values), nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.moveFocus(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
if s.focused >= 0 && s.focused < len(s.fields) {
|
||||||
|
s.fields[s.focused].input, cmd = s.fields[s.focused].input.Update(msg)
|
||||||
|
}
|
||||||
|
return s, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *formScreen) View() string {
|
||||||
|
header := renderHeader(s.title, s.desc, s.errText)
|
||||||
|
body := strings.Builder{}
|
||||||
|
for i, field := range s.fields {
|
||||||
|
label := StyleLabel.Render(field.label)
|
||||||
|
if i == s.focused {
|
||||||
|
label = StyleHighlight.Render(field.label)
|
||||||
|
}
|
||||||
|
body.WriteString(label)
|
||||||
|
if field.desc != "" {
|
||||||
|
body.WriteString("\n")
|
||||||
|
body.WriteString(StyleSubtle.Render(field.desc))
|
||||||
|
}
|
||||||
|
body.WriteString("\n")
|
||||||
|
body.WriteString(field.input.View())
|
||||||
|
body.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
footer := renderFooter(s.footer, false)
|
||||||
|
return header + body.String() + footer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *formScreen) moveFocus(delta int) {
|
||||||
|
if len(s.fields) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.fields[s.focused].input.Blur()
|
||||||
|
s.focused = (s.focused + delta + len(s.fields)) % len(s.fields)
|
||||||
|
s.fields[s.focused].input.Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *formScreen) validateAll() (map[string]string, error) {
|
||||||
|
values := make(map[string]string, len(s.fields))
|
||||||
|
for _, field := range s.fields {
|
||||||
|
value := strings.TrimSpace(field.input.Value())
|
||||||
|
if field.required && value == "" {
|
||||||
|
return nil, fmt.Errorf("%s is required", field.label)
|
||||||
|
}
|
||||||
|
if field.validate != nil {
|
||||||
|
if err := field.validate(value); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values[field.key] = value
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type downloadProgressMsg struct {
|
||||||
|
downloaded int64
|
||||||
|
total int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type downloadDoneMsg struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type downloadScreen struct {
|
||||||
|
state *wizardState
|
||||||
|
title string
|
||||||
|
desc []string
|
||||||
|
modelID string
|
||||||
|
progress int
|
||||||
|
total int64
|
||||||
|
footer string
|
||||||
|
errText string
|
||||||
|
onSuccess func() screen
|
||||||
|
onCancel func() screen
|
||||||
|
updates chan tea.Msg
|
||||||
|
started bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDownloadScreen(state *wizardState, title string, desc []string, modelID string, onSuccess func() screen, onCancel func() screen) *downloadScreen {
|
||||||
|
return &downloadScreen{
|
||||||
|
state: state,
|
||||||
|
title: title,
|
||||||
|
desc: desc,
|
||||||
|
modelID: modelID,
|
||||||
|
onSuccess: onSuccess,
|
||||||
|
onCancel: onCancel,
|
||||||
|
updates: make(chan tea.Msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *downloadScreen) Init() tea.Cmd {
|
||||||
|
if s.started {
|
||||||
|
return listenForDownload(s.updates)
|
||||||
|
}
|
||||||
|
s.started = true
|
||||||
|
return tea.Batch(s.startDownloadCmd(), listenForDownload(s.updates))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *downloadScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case downloadProgressMsg:
|
||||||
|
s.total = msg.total
|
||||||
|
if msg.total > 0 {
|
||||||
|
s.progress = int(msg.downloaded * 100 / msg.total)
|
||||||
|
}
|
||||||
|
return s, listenForDownload(s.updates)
|
||||||
|
case downloadDoneMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
s.errText = msg.err.Error()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
if s.onSuccess != nil {
|
||||||
|
return s.onSuccess(), nil
|
||||||
|
}
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "q":
|
||||||
|
if s.onCancel != nil {
|
||||||
|
return s.onCancel(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *downloadScreen) View() string {
|
||||||
|
header := renderHeader(s.title, s.desc, s.errText)
|
||||||
|
progressLine := "Downloading"
|
||||||
|
if s.total > 0 {
|
||||||
|
progressLine = fmt.Sprintf("Downloading... %d%%", s.progress)
|
||||||
|
}
|
||||||
|
body := StyleMuted.Render(progressLine) + "\n\n"
|
||||||
|
footer := renderFooter(s.footer, false)
|
||||||
|
return header + body + footer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *downloadScreen) startDownloadCmd() tea.Cmd {
|
||||||
|
modelID := s.modelID
|
||||||
|
ch := s.updates
|
||||||
|
return func() tea.Msg {
|
||||||
|
err := downloadWhisperModel(modelID, func(downloaded, total int64) {
|
||||||
|
ch <- downloadProgressMsg{downloaded: downloaded, total: total}
|
||||||
|
})
|
||||||
|
ch <- downloadDoneMsg{err: err}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenForDownload(ch <-chan tea.Msg) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
msg, ok := <-ch
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func itemsToList(items []optionItem) []list.Item {
|
||||||
|
result := make([]list.Item, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
result[i] = item
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleItemsToList(items []toggleItem) []list.Item {
|
||||||
|
result := make([]list.Item, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
result[i] = item
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func listToToggleItems(items []list.Item) []toggleItem {
|
||||||
|
result := make([]toggleItem, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if t, ok := item.(toggleItem); ok {
|
||||||
|
result = append(result, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderHeader(title string, desc []string, errText string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
if title != "" {
|
||||||
|
b.WriteString(StyleHeader.Render(title))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
for _, line := range desc {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(StyleMuted.Render(line))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
if errText != "" {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(StyleError.Render(errText))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderFooter(extra string, filtering bool) string {
|
||||||
|
if extra != "" {
|
||||||
|
return StyleSubtle.Render(extra)
|
||||||
|
}
|
||||||
|
if filtering {
|
||||||
|
return StyleSubtle.Render("enter apply • esc clear")
|
||||||
|
}
|
||||||
|
return StyleSubtle.Render("enter select • esc back")
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeInputField(key, label, desc, value, placeholder string, validate func(string) error) formField {
|
||||||
|
input := textinput.New()
|
||||||
|
input.SetValue(value)
|
||||||
|
input.Placeholder = placeholder
|
||||||
|
input.Prompt = ""
|
||||||
|
input.Cursor.Style = lipgloss.NewStyle().Foreground(ColorPrimary)
|
||||||
|
return formField{key: key, label: label, desc: desc, input: input, validate: validate}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDurationOrEmpty(value string) (time.Duration, error) {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return 0, fmt.Errorf("duration is required")
|
||||||
|
}
|
||||||
|
return time.ParseDuration(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/leonardotrapani/hyprvoice/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigureResult holds the configuration result from the TUI.
|
||||||
|
type ConfigureResult struct {
|
||||||
|
Config *config.Config
|
||||||
|
Cancelled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type screen interface {
|
||||||
|
Init() tea.Cmd
|
||||||
|
Update(tea.Msg) (screen, tea.Cmd)
|
||||||
|
View() string
|
||||||
|
}
|
||||||
|
|
||||||
|
type wizardState struct {
|
||||||
|
cfg *config.Config
|
||||||
|
onboarding bool
|
||||||
|
cancelled bool
|
||||||
|
err error
|
||||||
|
result *ConfigureResult
|
||||||
|
}
|
||||||
|
|
||||||
|
type optionItem struct {
|
||||||
|
title string
|
||||||
|
desc string
|
||||||
|
value string
|
||||||
|
disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i optionItem) Title() string { return i.title }
|
||||||
|
func (i optionItem) Description() string { return i.desc }
|
||||||
|
func (i optionItem) FilterValue() string {
|
||||||
|
return strings.TrimSpace(i.title + " " + i.desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
type toggleItem struct {
|
||||||
|
title string
|
||||||
|
desc string
|
||||||
|
value string
|
||||||
|
selected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i toggleItem) Title() string {
|
||||||
|
prefix := "[ ]"
|
||||||
|
if i.selected {
|
||||||
|
prefix = "[x]"
|
||||||
|
}
|
||||||
|
return prefix + " " + i.title
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i toggleItem) Description() string { return i.desc }
|
||||||
|
func (i toggleItem) FilterValue() string {
|
||||||
|
return strings.TrimSpace(i.title + " " + i.desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
type modelOption struct {
|
||||||
|
ID string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/leonardotrapani/hyprvoice/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type wizardModel struct {
|
||||||
|
state *wizardState
|
||||||
|
screen screen
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWizardModel(state *wizardState, start screen) wizardModel {
|
||||||
|
return wizardModel{state: state, screen: start}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m wizardModel) Init() tea.Cmd {
|
||||||
|
if m.screen == nil {
|
||||||
|
return tea.Quit
|
||||||
|
}
|
||||||
|
return m.screen.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
if msg.String() == "ctrl+c" {
|
||||||
|
m.state.cancelled = true
|
||||||
|
m.state.result = &ConfigureResult{Cancelled: true}
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.screen == nil {
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
|
||||||
|
next, cmd := m.screen.Update(msg)
|
||||||
|
if next == nil {
|
||||||
|
if m.state.result == nil {
|
||||||
|
m.state.result = &ConfigureResult{Config: m.state.cfg, Cancelled: m.state.cancelled}
|
||||||
|
}
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
if next != m.screen {
|
||||||
|
var sizeCmd tea.Cmd
|
||||||
|
if m.width > 0 && m.height > 0 {
|
||||||
|
updated, scmd := next.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
|
||||||
|
if updated == nil {
|
||||||
|
if m.state.result == nil {
|
||||||
|
m.state.result = &ConfigureResult{Config: m.state.cfg, Cancelled: m.state.cancelled}
|
||||||
|
}
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
next = updated
|
||||||
|
sizeCmd = scmd
|
||||||
|
}
|
||||||
|
m.screen = next
|
||||||
|
initCmd := m.screen.Init()
|
||||||
|
return m, tea.Batch(cmd, sizeCmd, initCmd)
|
||||||
|
}
|
||||||
|
m.screen = next
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m wizardModel) View() string {
|
||||||
|
if m.screen == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m.screen.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the TUI configuration wizard.
|
||||||
|
// If onboarding is true, forces the guided wizard flow even if config exists.
|
||||||
|
func Run(existingConfig *config.Config, onboarding bool) (*ConfigureResult, error) {
|
||||||
|
if existingConfig == nil {
|
||||||
|
return nil, fmt.Errorf("config is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
state := &wizardState{cfg: existingConfig}
|
||||||
|
if onboarding || !hasUserChanges(existingConfig) {
|
||||||
|
state.onboarding = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var start screen
|
||||||
|
if state.onboarding {
|
||||||
|
start = newWelcomeScreen(state)
|
||||||
|
} else {
|
||||||
|
start = newMenuScreen(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
model := newWizardModel(state, start)
|
||||||
|
if _, err := tea.NewProgram(model, tea.WithAltScreen()).Run(); err != nil {
|
||||||
|
return &ConfigureResult{Cancelled: true}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.err != nil {
|
||||||
|
return &ConfigureResult{Cancelled: true}, state.err
|
||||||
|
}
|
||||||
|
if state.result == nil {
|
||||||
|
state.result = &ConfigureResult{Config: existingConfig, Cancelled: state.cancelled}
|
||||||
|
}
|
||||||
|
return state.result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/leonardotrapani/hyprvoice/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWizardMenuTransitionAppliesSize(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
state := &wizardState{cfg: cfg}
|
||||||
|
model := newWizardModel(state, newMenuScreen(state))
|
||||||
|
|
||||||
|
updated, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
|
||||||
|
model = updated.(wizardModel)
|
||||||
|
|
||||||
|
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
model = updated.(wizardModel)
|
||||||
|
|
||||||
|
listScreen, ok := model.screen.(*listScreen)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected list screen after selection, got %T", model.screen)
|
||||||
|
}
|
||||||
|
if listScreen.list.Width() <= 0 || listScreen.list.Height() <= 0 {
|
||||||
|
t.Fatalf("expected list size to be set, got width=%d height=%d", listScreen.list.Width(), listScreen.list.Height())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user