tui: migrate Bubble Tea v1 → v2 (charm.land), enable Shift+Enter
Move the Go TUI from github.com/charmbracelet/{bubbletea,lipgloss} to the v2
ecosystem at charm.land/{bubbletea,lipgloss}/v2 (Go 1.25). v2's kitty keyboard
disambiguation is on by default, so Shift+Enter now reliably inserts a newline
in the composer (Ctrl+J / Alt+Enter kept as fallbacks for non-kitty terminals).
Approach: rather than rewrite ~190 key-match sites to v2's (Code, Mod) idiom, a
small shim (internal/app/key.go) converts a v2 KeyPressMsg into the v1-shaped
keyMsg the handlers already expect, at the single Update boundary. The rest is
mechanical:
- key constants tea.Key* → shim consts; tea.KeyMsg → keyMsg.
- lipgloss.Color is now a func returning color.Color, not a type → fields/params
retyped to image/color.Color (theme/diff/overlays/view).
- v2 View() returns tea.View: render() builds the string, View() wraps it and
carries AltScreen (alt-screen is a per-frame View field now, not a program opt).
- WithWhitespaceBackground/Foreground → WithWhitespaceStyle(Style).
- preview: drop lipgloss.SetColorProfile (v2 renders truecolor by default).
Build, vet, gofmt, and the full test suite are green; preview renders truecolor
across kinds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,14 +7,13 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/correx/tui-go/internal/app"
|
"github.com/correx/tui-go/internal/app"
|
||||||
"github.com/muesli/termenv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
lipgloss.SetColorProfile(termenv.TrueColor)
|
// lipgloss v2 renders full truecolor ANSI by default (color downsampling now
|
||||||
|
// lives in the terminal writer, not a global profile), which is exactly what a
|
||||||
|
// static screenshot frame wants — so no color-profile setup is needed.
|
||||||
kind := "idle"
|
kind := "idle"
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
kind = os.Args[1]
|
kind = os.Args[1]
|
||||||
|
|||||||
+16
-15
@@ -1,44 +1,45 @@
|
|||||||
module github.com/correx/tui-go
|
module github.com/correx/tui-go
|
||||||
|
|
||||||
go 1.24.2
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
charm.land/bubbletea/v2 v2.0.7
|
||||||
|
charm.land/lipgloss/v2 v2.0.4
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
|
||||||
github.com/charmbracelet/glamour v1.0.0
|
github.com/charmbracelet/glamour v1.0.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
github.com/charmbracelet/x/ansi v0.11.7
|
||||||
github.com/charmbracelet/x/ansi v0.11.6
|
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/muesli/termenv v0.16.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
|
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.4.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-runewidth v0.0.23 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
github.com/microcosm-cc/bluemonday v1.0.27 // 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/reflow v0.3.0 // indirect
|
github.com/muesli/reflow v0.3.0 // 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/yuin/goldmark v1.7.13 // indirect
|
github.com/yuin/goldmark v1.7.13 // indirect
|
||||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||||
golang.org/x/net v0.38.0 // indirect
|
golang.org/x/net v0.38.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
golang.org/x/term v0.36.0 // indirect
|
golang.org/x/term v0.36.0 // indirect
|
||||||
golang.org/x/text v0.30.0 // indirect
|
golang.org/x/text v0.30.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
+30
-29
@@ -1,3 +1,7 @@
|
|||||||
|
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
|
||||||
|
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
|
||||||
|
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
|
||||||
|
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
||||||
@@ -6,57 +10,53 @@ github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg
|
|||||||
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
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.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
|
||||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
|
||||||
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
|
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
|
||||||
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
|
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
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/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
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/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.4.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=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
|
||||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||||
@@ -77,10 +77,11 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM
|
|||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// inSessionModel builds an in-session model with deliberately long fields (model name,
|
// inSessionModel builds an in-session model with deliberately long fields (model name,
|
||||||
@@ -40,7 +40,7 @@ func assertNoOverflow(t *testing.T, out string, w int) {
|
|||||||
func TestViewNeverOverflowsWidth(t *testing.T) {
|
func TestViewNeverOverflowsWidth(t *testing.T) {
|
||||||
for _, w := range []int{40, 50, 60, 80, 95, 96, 110, 160} {
|
for _, w := range []int{40, 50, 60, 80, 95, 96, 110, 160} {
|
||||||
m := inSessionModel(w, 30)
|
m := inSessionModel(w, 30)
|
||||||
assertNoOverflow(t, m.View(), w)
|
assertNoOverflow(t, m.render(), w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ func TestStatsOverlayNeverOverflowsWidth(t *testing.T) {
|
|||||||
m.overlay = OverlayStats
|
m.overlay = OverlayStats
|
||||||
m.statsFor = m.selectedID
|
m.statsFor = m.selectedID
|
||||||
m.stats = sampleStats()
|
m.stats = sampleStats()
|
||||||
assertNoOverflow(t, m.View(), w)
|
assertNoOverflow(t, m.render(), w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/correx/tui-go/internal/protocol"
|
"github.com/correx/tui-go/internal/protocol"
|
||||||
"github.com/correx/tui-go/internal/ws"
|
"github.com/correx/tui-go/internal/ws"
|
||||||
)
|
)
|
||||||
@@ -40,12 +39,12 @@ func apprReq(reqID, tier string) protocol.ServerMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// applyKey drives a key through the production handleKey entry and returns the model.
|
// applyKey drives a key through the production handleKey entry and returns the model.
|
||||||
func applyKey(m Model, k tea.KeyMsg) Model {
|
func applyKey(m Model, k keyMsg) Model {
|
||||||
updated, _ := m.handleKey(k)
|
updated, _ := m.handleKey(k)
|
||||||
return updated.(Model)
|
return updated.(Model)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runeKey(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} }
|
func runeKey(r rune) keyMsg { return keyMsg{Type: keyRunes, Runes: []rune{r}} }
|
||||||
|
|
||||||
// decodeApproval pulls the single ApprovalResponse frame off the client and returns
|
// decodeApproval pulls the single ApprovalResponse frame off the client and returns
|
||||||
// its request id + decision. Fails if there isn't exactly one decision frame.
|
// its request id + decision. Fails if there isn't exactly one decision frame.
|
||||||
@@ -146,7 +145,7 @@ func TestApprovalHighTierArmCancelledByNavKey(t *testing.T) {
|
|||||||
// no decision may be emitted.
|
// no decision may be emitted.
|
||||||
m, client := approvalModel(apprReq("req-1", "T3"), apprReq("req-2", "T3"))
|
m, client := approvalModel(apprReq("req-1", "T3"), apprReq("req-2", "T3"))
|
||||||
m = applyKey(m, runeKey('y')) // arm req-1
|
m = applyKey(m, runeKey('y')) // arm req-1
|
||||||
m = applyKey(m, tea.KeyMsg{Type: tea.KeyDown})
|
m = applyKey(m, keyMsg{Type: keyDown})
|
||||||
if m.approvalArmed {
|
if m.approvalArmed {
|
||||||
t.Fatalf("nav key should disarm")
|
t.Fatalf("nav key should disarm")
|
||||||
}
|
}
|
||||||
@@ -168,7 +167,7 @@ func TestApprovalQueueNavigatesAndCounts(t *testing.T) {
|
|||||||
t.Fatalf("band should show queue count 1/3:\n%s", out)
|
t.Fatalf("band should show queue count 1/3:\n%s", out)
|
||||||
}
|
}
|
||||||
// ↓ advances the selection; Pending follows the cursor.
|
// ↓ advances the selection; Pending follows the cursor.
|
||||||
m = applyKey(m, tea.KeyMsg{Type: tea.KeyDown})
|
m = applyKey(m, keyMsg{Type: keyDown})
|
||||||
s = m.session("s1")
|
s = m.session("s1")
|
||||||
if s.PendingIdx != 1 || s.Pending.RequestID != "req-2" {
|
if s.PendingIdx != 1 || s.Pending.RequestID != "req-2" {
|
||||||
t.Fatalf("↓ should select req-2, got idx=%d id=%s", s.PendingIdx, s.Pending.RequestID)
|
t.Fatalf("↓ should select req-2, got idx=%d id=%s", s.PendingIdx, s.Pending.RequestID)
|
||||||
@@ -178,7 +177,7 @@ func TestApprovalQueueNavigatesAndCounts(t *testing.T) {
|
|||||||
t.Fatalf("band should show 2/3 after ↓:\n%s", out)
|
t.Fatalf("band should show 2/3 after ↓:\n%s", out)
|
||||||
}
|
}
|
||||||
// ↑ wraps back.
|
// ↑ wraps back.
|
||||||
m = applyKey(m, tea.KeyMsg{Type: tea.KeyUp})
|
m = applyKey(m, keyMsg{Type: keyUp})
|
||||||
if m.session("s1").PendingIdx != 0 {
|
if m.session("s1").PendingIdx != 0 {
|
||||||
t.Fatalf("↑ should return to index 0")
|
t.Fatalf("↑ should return to index 0")
|
||||||
}
|
}
|
||||||
@@ -238,7 +237,7 @@ func TestApprovalRejectAndSteerRoute(t *testing.T) {
|
|||||||
for _, r := range "use sudo" {
|
for _, r := range "use sudo" {
|
||||||
m2 = applyKey(m2, runeKey(r))
|
m2 = applyKey(m2, runeKey(r))
|
||||||
}
|
}
|
||||||
m2 = applyKey(m2, tea.KeyMsg{Type: tea.KeyEnter})
|
m2 = applyKey(m2, keyMsg{Type: keyEnter})
|
||||||
frames := client2.Drain()
|
frames := client2.Drain()
|
||||||
var note string
|
var note string
|
||||||
for _, f := range frames {
|
for _, f := range frames {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package app
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/correx/tui-go/internal/protocol"
|
"github.com/correx/tui-go/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ func (m Model) clarAnswerValue(i int, q ClarQuestion) string {
|
|||||||
|
|
||||||
// --- key handling ---
|
// --- key handling ---
|
||||||
|
|
||||||
func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) handleClarificationKey(k keyMsg) (tea.Model, tea.Cmd) {
|
||||||
s := m.session(m.selectedID)
|
s := m.session(m.selectedID)
|
||||||
if s == nil || s.Clar == nil {
|
if s == nil || s.Clar == nil {
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -102,21 +102,21 @@ func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m.handleClarTyping(k), nil
|
return m.handleClarTyping(k), nil
|
||||||
}
|
}
|
||||||
switch k.Type {
|
switch k.Type {
|
||||||
case tea.KeyEsc:
|
case keyEsc:
|
||||||
m.clarDismissed = true
|
m.clarDismissed = true
|
||||||
case tea.KeyUp:
|
case keyUp:
|
||||||
m.clarFocusMove(-1, qs)
|
m.clarFocusMove(-1, qs)
|
||||||
case tea.KeyDown:
|
case keyDown:
|
||||||
m.clarFocusMove(1, qs)
|
m.clarFocusMove(1, qs)
|
||||||
case tea.KeyLeft:
|
case keyLeft:
|
||||||
m.clarCursorMove(-1, qs)
|
m.clarCursorMove(-1, qs)
|
||||||
case tea.KeyRight:
|
case keyRight:
|
||||||
m.clarCursorMove(1, qs)
|
m.clarCursorMove(1, qs)
|
||||||
case tea.KeySpace:
|
case keySpace:
|
||||||
m.clarSelect(qs)
|
m.clarSelect(qs)
|
||||||
case tea.KeyEnter:
|
case keyEnter:
|
||||||
return m.clarSubmit()
|
return m.clarSubmit()
|
||||||
case tea.KeyRunes:
|
case keyRunes:
|
||||||
switch string(k.Runes) {
|
switch string(k.Runes) {
|
||||||
case "k":
|
case "k":
|
||||||
m.clarFocusMove(-1, qs)
|
m.clarFocusMove(-1, qs)
|
||||||
@@ -136,24 +136,24 @@ func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) handleClarTyping(k tea.KeyMsg) tea.Model {
|
func (m Model) handleClarTyping(k keyMsg) tea.Model {
|
||||||
if m.clarFocus >= len(m.clarText) {
|
if m.clarFocus >= len(m.clarText) {
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
switch k.Type {
|
switch k.Type {
|
||||||
case tea.KeyEsc:
|
case keyEsc:
|
||||||
m.clarTyping = false
|
m.clarTyping = false
|
||||||
case tea.KeyEnter:
|
case keyEnter:
|
||||||
m.clarTyping = false
|
m.clarTyping = false
|
||||||
// committing custom text supersedes any chosen option for this question
|
// committing custom text supersedes any chosen option for this question
|
||||||
if m.clarFocus < len(m.clarChosen) {
|
if m.clarFocus < len(m.clarChosen) {
|
||||||
m.clarChosen[m.clarFocus] = map[int]bool{}
|
m.clarChosen[m.clarFocus] = map[int]bool{}
|
||||||
}
|
}
|
||||||
case tea.KeyBackspace:
|
case keyBackspace:
|
||||||
if t := m.clarText[m.clarFocus]; len(t) > 0 {
|
if t := m.clarText[m.clarFocus]; len(t) > 0 {
|
||||||
m.clarText[m.clarFocus] = t[:len(t)-1]
|
m.clarText[m.clarFocus] = t[:len(t)-1]
|
||||||
}
|
}
|
||||||
case tea.KeyRunes, tea.KeySpace:
|
case keyRunes, keySpace:
|
||||||
m.clarText[m.clarFocus] += string(k.Runes)
|
m.clarText[m.clarFocus] += string(k.Runes)
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/correx/tui-go/internal/protocol"
|
"github.com/correx/tui-go/internal/protocol"
|
||||||
"github.com/correx/tui-go/internal/ws"
|
"github.com/correx/tui-go/internal/ws"
|
||||||
)
|
)
|
||||||
@@ -43,13 +42,13 @@ func TestClarificationEntersStateAndRenders(t *testing.T) {
|
|||||||
func TestClarificationSelectAndSubmit(t *testing.T) {
|
func TestClarificationSelectAndSubmit(t *testing.T) {
|
||||||
m := clarModel()
|
m := clarModel()
|
||||||
// move the option cursor to "Vue" (index 1) and select it
|
// move the option cursor to "Vue" (index 1) and select it
|
||||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRight})
|
m = applyClarKey(m, keyMsg{Type: keyRight})
|
||||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace})
|
m = applyClarKey(m, keyMsg{Type: keySpace})
|
||||||
if !m.clarChosenAt(0, 1) {
|
if !m.clarChosenAt(0, 1) {
|
||||||
t.Fatalf("expected option 1 chosen, chosen=%v", m.clarChosen)
|
t.Fatalf("expected option 1 chosen, chosen=%v", m.clarChosen)
|
||||||
}
|
}
|
||||||
// submit
|
// submit
|
||||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter})
|
m = applyClarKey(m, keyMsg{Type: keyEnter})
|
||||||
if s := m.session("s1"); s == nil || s.Clar != nil {
|
if s := m.session("s1"); s == nil || s.Clar != nil {
|
||||||
t.Fatalf("expected clarification cleared after submit")
|
t.Fatalf("expected clarification cleared after submit")
|
||||||
}
|
}
|
||||||
@@ -68,8 +67,8 @@ func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
// answer only the first question, then attempt submit
|
// answer only the first question, then attempt submit
|
||||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace})
|
m = applyClarKey(m, keyMsg{Type: keySpace})
|
||||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter})
|
m = applyClarKey(m, keyMsg{Type: keyEnter})
|
||||||
if s := m.session("s1"); s == nil || s.Clar == nil {
|
if s := m.session("s1"); s == nil || s.Clar == nil {
|
||||||
t.Fatalf("expected clarification to remain with partial answers")
|
t.Fatalf("expected clarification to remain with partial answers")
|
||||||
}
|
}
|
||||||
@@ -80,14 +79,14 @@ func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) {
|
|||||||
|
|
||||||
func TestClarificationCustomTextAnswer(t *testing.T) {
|
func TestClarificationCustomTextAnswer(t *testing.T) {
|
||||||
m := clarModel()
|
m := clarModel()
|
||||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")})
|
m = applyClarKey(m, keyMsg{Type: keyRunes, Runes: []rune("e")})
|
||||||
if !m.clarTyping {
|
if !m.clarTyping {
|
||||||
t.Fatalf("expected typing mode after 'e'")
|
t.Fatalf("expected typing mode after 'e'")
|
||||||
}
|
}
|
||||||
for _, r := range "Solid" {
|
for _, r := range "Solid" {
|
||||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
m = applyClarKey(m, keyMsg{Type: keyRunes, Runes: []rune{r}})
|
||||||
}
|
}
|
||||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter})
|
m = applyClarKey(m, keyMsg{Type: keyEnter})
|
||||||
if m.clarTyping {
|
if m.clarTyping {
|
||||||
t.Fatalf("expected typing committed")
|
t.Fatalf("expected typing committed")
|
||||||
}
|
}
|
||||||
@@ -99,7 +98,7 @@ func TestClarificationCustomTextAnswer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyClarKey(m Model, k tea.KeyMsg) Model {
|
func applyClarKey(m Model, k keyMsg) Model {
|
||||||
updated, _ := m.handleClarificationKey(k)
|
updated, _ := m.handleClarificationKey(k)
|
||||||
return updated.(Model)
|
return updated.(Model)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cmdcard.go is layer 1 of tui-requirements §5 — the deterministic command-approval
|
// cmdcard.go is layer 1 of tui-requirements §5 — the deterministic command-approval
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package app
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/correx/tui-go/internal/protocol"
|
"github.com/correx/tui-go/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,40 +23,40 @@ func (m *Model) openConfig() {
|
|||||||
|
|
||||||
// handleConfigKey owns every key while the config overlay is open. In edit mode it captures the
|
// handleConfigKey owns every key while the config overlay is open. In edit mode it captures the
|
||||||
// value being typed; otherwise it navigates fields, stages edits, and saves.
|
// value being typed; otherwise it navigates fields, stages edits, and saves.
|
||||||
func (m Model) handleConfigKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) handleConfigKey(k keyMsg) (tea.Model, tea.Cmd) {
|
||||||
if m.configEditing {
|
if m.configEditing {
|
||||||
switch k.Type {
|
switch k.Type {
|
||||||
case tea.KeyEsc:
|
case keyEsc:
|
||||||
m.configEditing = false
|
m.configEditing = false
|
||||||
m.configEditBuf = ""
|
m.configEditBuf = ""
|
||||||
case tea.KeyEnter:
|
case keyEnter:
|
||||||
if m.configIndex >= 0 && m.configIndex < len(m.configFields) {
|
if m.configIndex >= 0 && m.configIndex < len(m.configFields) {
|
||||||
m.configStaged[m.configFields[m.configIndex].Key] = m.configEditBuf
|
m.configStaged[m.configFields[m.configIndex].Key] = m.configEditBuf
|
||||||
}
|
}
|
||||||
m.configEditing = false
|
m.configEditing = false
|
||||||
m.configEditBuf = ""
|
m.configEditBuf = ""
|
||||||
case tea.KeyBackspace:
|
case keyBackspace:
|
||||||
if n := len(m.configEditBuf); n > 0 {
|
if n := len(m.configEditBuf); n > 0 {
|
||||||
m.configEditBuf = m.configEditBuf[:n-1]
|
m.configEditBuf = m.configEditBuf[:n-1]
|
||||||
}
|
}
|
||||||
case tea.KeyRunes, tea.KeySpace:
|
case keyRunes, keySpace:
|
||||||
m.configEditBuf += string(k.Runes)
|
m.configEditBuf += string(k.Runes)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case k.Type == tea.KeyEsc || runeIs(k, "g"):
|
case k.Type == keyEsc || runeIs(k, "g"):
|
||||||
m.overlay = OverlayNone
|
m.overlay = OverlayNone
|
||||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
case k.Type == keyUp || runeIs(k, "k"):
|
||||||
if m.configIndex > 0 {
|
if m.configIndex > 0 {
|
||||||
m.configIndex--
|
m.configIndex--
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyDown || runeIs(k, "j"):
|
case k.Type == keyDown || runeIs(k, "j"):
|
||||||
if m.configIndex < len(m.configFields)-1 {
|
if m.configIndex < len(m.configFields)-1 {
|
||||||
m.configIndex++
|
m.configIndex++
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyEnter || k.Type == tea.KeySpace:
|
case k.Type == keyEnter || k.Type == keySpace:
|
||||||
m = m.actOnConfigField()
|
m = m.actOnConfigField()
|
||||||
case runeIs(k, "s"):
|
case runeIs(k, "s"):
|
||||||
if len(m.configStaged) > 0 {
|
if len(m.configStaged) > 0 {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/correx/tui-go/internal/protocol"
|
"github.com/correx/tui-go/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,7 +22,7 @@ func configModel() Model {
|
|||||||
|
|
||||||
func TestConfigToggleBoolStagesFlippedValue(t *testing.T) {
|
func TestConfigToggleBoolStagesFlippedValue(t *testing.T) {
|
||||||
m := configModel()
|
m := configModel()
|
||||||
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
|
updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter})
|
||||||
m = updated.(Model)
|
m = updated.(Model)
|
||||||
if m.configStaged["project.enabled"] != "true" {
|
if m.configStaged["project.enabled"] != "true" {
|
||||||
t.Fatalf("want staged project.enabled=true, got %q", m.configStaged["project.enabled"])
|
t.Fatalf("want staged project.enabled=true, got %q", m.configStaged["project.enabled"])
|
||||||
@@ -40,7 +39,7 @@ func TestConfigToggleBoolStagesFlippedValue(t *testing.T) {
|
|||||||
func TestConfigEnumCycles(t *testing.T) {
|
func TestConfigEnumCycles(t *testing.T) {
|
||||||
m := configModel()
|
m := configModel()
|
||||||
m.configIndex = 1 // tui.theme
|
m.configIndex = 1 // tui.theme
|
||||||
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
|
updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter})
|
||||||
m = updated.(Model)
|
m = updated.(Model)
|
||||||
if m.configStaged["tui.theme"] != "light" {
|
if m.configStaged["tui.theme"] != "light" {
|
||||||
t.Fatalf("want staged tui.theme=light (next after dark), got %q", m.configStaged["tui.theme"])
|
t.Fatalf("want staged tui.theme=light (next after dark), got %q", m.configStaged["tui.theme"])
|
||||||
@@ -51,7 +50,7 @@ func TestConfigIntEditCommitsBuffer(t *testing.T) {
|
|||||||
m := configModel()
|
m := configModel()
|
||||||
m.configIndex = 2 // router.generation.max_tokens
|
m.configIndex = 2 // router.generation.max_tokens
|
||||||
// Enter edit mode.
|
// Enter edit mode.
|
||||||
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
|
updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter})
|
||||||
m = updated.(Model)
|
m = updated.(Model)
|
||||||
if !m.configEditing {
|
if !m.configEditing {
|
||||||
t.Fatalf("expected edit mode after enter on INT field")
|
t.Fatalf("expected edit mode after enter on INT field")
|
||||||
@@ -59,10 +58,10 @@ func TestConfigIntEditCommitsBuffer(t *testing.T) {
|
|||||||
// Clear the seeded buffer and type a new value.
|
// Clear the seeded buffer and type a new value.
|
||||||
m.configEditBuf = ""
|
m.configEditBuf = ""
|
||||||
for _, r := range "1024" {
|
for _, r := range "1024" {
|
||||||
updated, _ = m.handleConfigKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
updated, _ = m.handleConfigKey(keyMsg{Type: keyRunes, Runes: []rune{r}})
|
||||||
m = updated.(Model)
|
m = updated.(Model)
|
||||||
}
|
}
|
||||||
updated, _ = m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
|
updated, _ = m.handleConfigKey(keyMsg{Type: keyEnter})
|
||||||
m = updated.(Model)
|
m = updated.(Model)
|
||||||
if m.configEditing {
|
if m.configEditing {
|
||||||
t.Fatalf("expected edit mode to end after commit")
|
t.Fatalf("expected edit mode to end after commit")
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ func PreviewFrame(kind string, w, h int) string {
|
|||||||
m.grantIndex = 1
|
m.grantIndex = 1
|
||||||
m.overlay = OverlayGrants
|
m.overlay = OverlayGrants
|
||||||
}
|
}
|
||||||
return m.View()
|
return m.render()
|
||||||
}
|
}
|
||||||
|
|
||||||
func sampleStats() *protocol.StatsDto {
|
func sampleStats() *protocol.StatsDto {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"image/color"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type diffKind int
|
type diffKind int
|
||||||
@@ -196,7 +197,7 @@ func (m Model) diffCell(num int, text string, kind diffKind, w int) string {
|
|||||||
if kind == diffBlank {
|
if kind == diffBlank {
|
||||||
return lipgloss.NewStyle().Background(t.P.Bg).Render(strings.Repeat(" ", w))
|
return lipgloss.NewStyle().Background(t.P.Bg).Render(strings.Repeat(" ", w))
|
||||||
}
|
}
|
||||||
var bg, textFg, signFg lipgloss.Color
|
var bg, textFg, signFg color.Color
|
||||||
sign := " "
|
sign := " "
|
||||||
switch kind {
|
switch kind {
|
||||||
case diffAdd:
|
case diffAdd:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ideacard.go renders the cross-session idea board (OverlayIdeas): the ideas the router
|
// ideacard.go renders the cross-session idea board (OverlayIdeas): the ideas the router
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/correx/tui-go/internal/protocol"
|
"github.com/correx/tui-go/internal/protocol"
|
||||||
"github.com/correx/tui-go/internal/ws"
|
"github.com/correx/tui-go/internal/ws"
|
||||||
)
|
)
|
||||||
@@ -43,7 +42,7 @@ func TestIdeaRemoveSendsDiscardAndDropsRow(t *testing.T) {
|
|||||||
m, client := ideaModel()
|
m, client := ideaModel()
|
||||||
_ = client.Drain() // drop the ListIdeas frame from openIdeas
|
_ = client.Drain() // drop the ListIdeas frame from openIdeas
|
||||||
|
|
||||||
updated, _ := m.handleOverlayKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
|
updated, _ := m.handleOverlayKey(keyMsg{Type: keyRunes, Runes: []rune("x")})
|
||||||
m = updated.(Model)
|
m = updated.(Model)
|
||||||
|
|
||||||
if len(m.ideas) != 1 || m.ideas[0].IdeaID != "i2" {
|
if len(m.ideas) != 1 || m.ideas[0].IdeaID != "i2" {
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
// bubbletea v2 collapsed key events into a single (Code rune, Mod KeyMod) form
|
||||||
|
// and dropped the v1 KeyType enum the handlers were written against. Rather than
|
||||||
|
// rewrite ~190 key-match sites, this shim adapts a v2 key press into the same
|
||||||
|
// v1-shaped struct (Type + Runes + modifier flags), so all the matching logic
|
||||||
|
// downstream stays untouched. The only place that ever touches the raw v2 key
|
||||||
|
// is toKeyMsg below, called once at the Update boundary.
|
||||||
|
|
||||||
|
type keyType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyRunes keyType = iota
|
||||||
|
keyEnter
|
||||||
|
keyUp
|
||||||
|
keyDown
|
||||||
|
keyLeft
|
||||||
|
keyRight
|
||||||
|
keySpace
|
||||||
|
keyEsc
|
||||||
|
keyBackspace
|
||||||
|
keyTab
|
||||||
|
keyPgUp
|
||||||
|
keyPgDown
|
||||||
|
keyCtrlA
|
||||||
|
keyCtrlC
|
||||||
|
keyCtrlD
|
||||||
|
keyCtrlJ
|
||||||
|
keyCtrlR
|
||||||
|
keyCtrlU
|
||||||
|
keyCtrlX
|
||||||
|
keyCtrlUp
|
||||||
|
keyCtrlDown
|
||||||
|
keyOther
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyMsg is the v1-shaped key event the handlers consume: a Type plus the typed
|
||||||
|
// Runes and modifier flags. Shift is newly meaningful under v2's kitty key
|
||||||
|
// disambiguation — it powers Shift+Enter newlines (see handleInsertKey).
|
||||||
|
type keyMsg struct {
|
||||||
|
Type keyType
|
||||||
|
Runes []rune
|
||||||
|
Alt bool
|
||||||
|
Shift bool
|
||||||
|
str string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k keyMsg) String() string { return k.str }
|
||||||
|
|
||||||
|
// toKeyMsg converts a bubbletea v2 key press into the shim's v1-shaped form.
|
||||||
|
func toKeyMsg(p tea.KeyPressMsg) keyMsg {
|
||||||
|
k := p.Key()
|
||||||
|
out := keyMsg{
|
||||||
|
Alt: k.Mod.Contains(tea.ModAlt),
|
||||||
|
Shift: k.Mod.Contains(tea.ModShift),
|
||||||
|
str: k.Keystroke(),
|
||||||
|
}
|
||||||
|
ctrl := k.Mod.Contains(tea.ModCtrl)
|
||||||
|
switch k.Code {
|
||||||
|
case tea.KeyEnter:
|
||||||
|
out.Type = keyEnter
|
||||||
|
case tea.KeyEscape:
|
||||||
|
out.Type = keyEsc
|
||||||
|
case tea.KeyTab:
|
||||||
|
out.Type = keyTab
|
||||||
|
case tea.KeyBackspace:
|
||||||
|
out.Type = keyBackspace
|
||||||
|
case tea.KeySpace:
|
||||||
|
out.Type = keySpace
|
||||||
|
out.Runes = []rune{' '}
|
||||||
|
case tea.KeyUp:
|
||||||
|
if ctrl {
|
||||||
|
out.Type = keyCtrlUp
|
||||||
|
} else {
|
||||||
|
out.Type = keyUp
|
||||||
|
}
|
||||||
|
case tea.KeyDown:
|
||||||
|
if ctrl {
|
||||||
|
out.Type = keyCtrlDown
|
||||||
|
} else {
|
||||||
|
out.Type = keyDown
|
||||||
|
}
|
||||||
|
case tea.KeyLeft:
|
||||||
|
out.Type = keyLeft
|
||||||
|
case tea.KeyRight:
|
||||||
|
out.Type = keyRight
|
||||||
|
case tea.KeyPgUp:
|
||||||
|
out.Type = keyPgUp
|
||||||
|
case tea.KeyPgDown:
|
||||||
|
out.Type = keyPgDown
|
||||||
|
default:
|
||||||
|
if ctrl {
|
||||||
|
switch k.Code {
|
||||||
|
case 'a':
|
||||||
|
out.Type = keyCtrlA
|
||||||
|
case 'c':
|
||||||
|
out.Type = keyCtrlC
|
||||||
|
case 'd':
|
||||||
|
out.Type = keyCtrlD
|
||||||
|
case 'j':
|
||||||
|
out.Type = keyCtrlJ
|
||||||
|
case 'r':
|
||||||
|
out.Type = keyCtrlR
|
||||||
|
case 'u':
|
||||||
|
out.Type = keyCtrlU
|
||||||
|
case 'x':
|
||||||
|
out.Type = keyCtrlX
|
||||||
|
default:
|
||||||
|
out.Type = keyOther
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if k.Text != "" {
|
||||||
|
out.Type = keyRunes
|
||||||
|
out.Runes = []rune(k.Text)
|
||||||
|
} else {
|
||||||
|
out.Type = keyOther
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/charmbracelet/x/ansi"
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image/color"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/charmbracelet/x/ansi"
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,8 +22,8 @@ import (
|
|||||||
func (m Model) center(modal string) string {
|
func (m Model) center(modal string) string {
|
||||||
if m.lastBase == "" {
|
if m.lastBase == "" {
|
||||||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal,
|
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal,
|
||||||
lipgloss.WithWhitespaceBackground(m.theme.P.BgDeep),
|
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().
|
||||||
lipgloss.WithWhitespaceForeground(m.theme.P.BgDeep))
|
Background(m.theme.P.BgDeep).Foreground(m.theme.P.BgDeep)))
|
||||||
}
|
}
|
||||||
if lipgloss.Height(modal) >= m.height && lipgloss.Width(modal) >= m.width {
|
if lipgloss.Height(modal) >= m.height && lipgloss.Width(modal) >= m.width {
|
||||||
return modal // already a full-screen composite; don't re-dim it
|
return modal // already a full-screen composite; don't re-dim it
|
||||||
@@ -143,7 +144,7 @@ func (m Model) titleLine(text string) string {
|
|||||||
return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Bold(true).Render(text)
|
return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Bold(true).Render(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
func mbg(t Theme, s string, fg lipgloss.Color) string {
|
func mbg(t Theme, s string, fg color.Color) string {
|
||||||
return lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(s)
|
return lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1217,7 +1218,7 @@ func shortID(id string) string {
|
|||||||
return id[:13] + "…"
|
return id[:13] + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
func tierColor(t Theme, tier int) lipgloss.Color {
|
func tierColor(t Theme, tier int) color.Color {
|
||||||
switch {
|
switch {
|
||||||
case tier >= 3:
|
case tier >= 3:
|
||||||
return t.P.Bad
|
return t.P.Bad
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package app
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/correx/tui-go/internal/protocol"
|
"github.com/correx/tui-go/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ func (m *Model) proposeInitState() {
|
|||||||
|
|
||||||
// --- key handling ---
|
// --- key handling ---
|
||||||
|
|
||||||
func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) handleProposeKey(k keyMsg) (tea.Model, tea.Cmd) {
|
||||||
s := m.session(m.selectedID)
|
s := m.session(m.selectedID)
|
||||||
if s == nil || s.Propose == nil {
|
if s == nil || s.Propose == nil {
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -34,15 +34,15 @@ func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
n := len(s.Propose.Candidates) // index n == the custom slot
|
n := len(s.Propose.Candidates) // index n == the custom slot
|
||||||
switch k.Type {
|
switch k.Type {
|
||||||
case tea.KeyEsc:
|
case keyEsc:
|
||||||
m.proposeDismissed = true
|
m.proposeDismissed = true
|
||||||
case tea.KeyUp:
|
case keyUp:
|
||||||
m.proposeCursor = clampInt(m.proposeCursor-1, 0, n)
|
m.proposeCursor = clampInt(m.proposeCursor-1, 0, n)
|
||||||
case tea.KeyDown:
|
case keyDown:
|
||||||
m.proposeCursor = clampInt(m.proposeCursor+1, 0, n)
|
m.proposeCursor = clampInt(m.proposeCursor+1, 0, n)
|
||||||
case tea.KeyEnter, tea.KeySpace:
|
case keyEnter, keySpace:
|
||||||
return m.proposeSubmit()
|
return m.proposeSubmit()
|
||||||
case tea.KeyRunes:
|
case keyRunes:
|
||||||
switch string(k.Runes) {
|
switch string(k.Runes) {
|
||||||
case "k":
|
case "k":
|
||||||
m.proposeCursor = clampInt(m.proposeCursor-1, 0, n)
|
m.proposeCursor = clampInt(m.proposeCursor-1, 0, n)
|
||||||
@@ -56,17 +56,17 @@ func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) handleProposeTyping(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) handleProposeTyping(k keyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch k.Type {
|
switch k.Type {
|
||||||
case tea.KeyEsc:
|
case keyEsc:
|
||||||
m.proposeTyping = false
|
m.proposeTyping = false
|
||||||
case tea.KeyEnter:
|
case keyEnter:
|
||||||
return m.proposeSubmit() // commit the custom answer and send it as chat
|
return m.proposeSubmit() // commit the custom answer and send it as chat
|
||||||
case tea.KeyBackspace:
|
case keyBackspace:
|
||||||
if len(m.proposeText) > 0 {
|
if len(m.proposeText) > 0 {
|
||||||
m.proposeText = m.proposeText[:len(m.proposeText)-1]
|
m.proposeText = m.proposeText[:len(m.proposeText)-1]
|
||||||
}
|
}
|
||||||
case tea.KeyRunes, tea.KeySpace:
|
case keyRunes, keySpace:
|
||||||
m.proposeText += string(k.Runes)
|
m.proposeText += string(k.Runes)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/correx/tui-go/internal/protocol"
|
"github.com/correx/tui-go/internal/protocol"
|
||||||
"github.com/correx/tui-go/internal/ws"
|
"github.com/correx/tui-go/internal/ws"
|
||||||
)
|
)
|
||||||
@@ -47,8 +46,8 @@ func TestProposeEntersStateAndRenders(t *testing.T) {
|
|||||||
func TestProposePickLaunchesChosenWorkflow(t *testing.T) {
|
func TestProposePickLaunchesChosenWorkflow(t *testing.T) {
|
||||||
m, client := proposeModel()
|
m, client := proposeModel()
|
||||||
// move cursor to the second candidate (role_pipeline) and launch it
|
// move cursor to the second candidate (role_pipeline) and launch it
|
||||||
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyDown})
|
m = applyProposeKey(m, keyMsg{Type: keyDown})
|
||||||
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter})
|
m = applyProposeKey(m, keyMsg{Type: keyEnter})
|
||||||
|
|
||||||
if s := m.session("s1"); s == nil || s.Propose != nil {
|
if s := m.session("s1"); s == nil || s.Propose != nil {
|
||||||
t.Fatalf("expected proposal cleared after launch")
|
t.Fatalf("expected proposal cleared after launch")
|
||||||
@@ -67,7 +66,7 @@ func TestProposePickLaunchesChosenWorkflow(t *testing.T) {
|
|||||||
|
|
||||||
func TestProposeFirstCandidateIsDefault(t *testing.T) {
|
func TestProposeFirstCandidateIsDefault(t *testing.T) {
|
||||||
m, client := proposeModel()
|
m, client := proposeModel()
|
||||||
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter}) // no nav → first candidate
|
m = applyProposeKey(m, keyMsg{Type: keyEnter}) // no nav → first candidate
|
||||||
wf, _ := decodeStart(t, client)
|
wf, _ := decodeStart(t, client)
|
||||||
if wf != "research" {
|
if wf != "research" {
|
||||||
t.Fatalf("expected first candidate launched by default, got %q", wf)
|
t.Fatalf("expected first candidate launched by default, got %q", wf)
|
||||||
@@ -76,14 +75,14 @@ func TestProposeFirstCandidateIsDefault(t *testing.T) {
|
|||||||
|
|
||||||
func TestProposeCustomAnswerContinuesChat(t *testing.T) {
|
func TestProposeCustomAnswerContinuesChat(t *testing.T) {
|
||||||
m, client := proposeModel()
|
m, client := proposeModel()
|
||||||
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")})
|
m = applyProposeKey(m, keyMsg{Type: keyRunes, Runes: []rune("e")})
|
||||||
if !m.proposeTyping {
|
if !m.proposeTyping {
|
||||||
t.Fatalf("expected typing mode after 'e'")
|
t.Fatalf("expected typing mode after 'e'")
|
||||||
}
|
}
|
||||||
for _, r := range "do it manually" {
|
for _, r := range "do it manually" {
|
||||||
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
m = applyProposeKey(m, keyMsg{Type: keyRunes, Runes: []rune{r}})
|
||||||
}
|
}
|
||||||
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter})
|
m = applyProposeKey(m, keyMsg{Type: keyEnter})
|
||||||
|
|
||||||
if s := m.session("s1"); s == nil || s.Propose != nil {
|
if s := m.session("s1"); s == nil || s.Propose != nil {
|
||||||
t.Fatalf("expected proposal cleared after custom answer")
|
t.Fatalf("expected proposal cleared after custom answer")
|
||||||
@@ -109,7 +108,7 @@ func TestProposeCustomAnswerContinuesChat(t *testing.T) {
|
|||||||
|
|
||||||
func TestProposeEscDismisses(t *testing.T) {
|
func TestProposeEscDismisses(t *testing.T) {
|
||||||
m, _ := proposeModel()
|
m, _ := proposeModel()
|
||||||
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEsc})
|
m = applyProposeKey(m, keyMsg{Type: keyEsc})
|
||||||
if m.displayState() != StateInSession {
|
if m.displayState() != StateInSession {
|
||||||
t.Fatalf("expected StateInSession after esc, got %v", m.displayState())
|
t.Fatalf("expected StateInSession after esc, got %v", m.displayState())
|
||||||
}
|
}
|
||||||
@@ -118,7 +117,7 @@ func TestProposeEscDismisses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyProposeKey(m Model, k tea.KeyMsg) Model {
|
func applyProposeKey(m Model, k keyMsg) Model {
|
||||||
updated, _ := m.handleProposeKey(k)
|
updated, _ := m.handleProposeKey(k)
|
||||||
return updated.(Model)
|
return updated.(Model)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ func renderSurface(m Model, s matrixSurface) string {
|
|||||||
var raw string
|
var raw string
|
||||||
switch s {
|
switch s {
|
||||||
case surfaceView:
|
case surfaceView:
|
||||||
raw = m.View()
|
raw = m.render()
|
||||||
case surfaceEvents:
|
case surfaceEvents:
|
||||||
// Render the event-stream rows at full panel width so a row's detail isn't
|
// Render the event-stream rows at full panel width so a row's detail isn't
|
||||||
// clipped at the slim right-panel edge (the production builder, just wide).
|
// clipped at the slim right-panel edge (the production builder, just wide).
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SessionSummary is one recent session from GET /sessions. Mirrors the server's
|
// SessionSummary is one recent session from GET /sessions. Mirrors the server's
|
||||||
@@ -107,19 +107,19 @@ func (m *Model) openSessions() tea.Cmd {
|
|||||||
|
|
||||||
// handleSessionsKey owns every key while the session browser is open: navigate
|
// handleSessionsKey owns every key while the session browser is open: navigate
|
||||||
// the roster, esc closes, enter resumes the selected session.
|
// the roster, esc closes, enter resumes the selected session.
|
||||||
func (m Model) handleSessionsKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) handleSessionsKey(k keyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch {
|
switch {
|
||||||
case k.Type == tea.KeyEsc || runeIs(k, "R"):
|
case k.Type == keyEsc || runeIs(k, "R"):
|
||||||
m.overlay = OverlayNone
|
m.overlay = OverlayNone
|
||||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
case k.Type == keyUp || runeIs(k, "k"):
|
||||||
if m.sessionListIndex > 0 {
|
if m.sessionListIndex > 0 {
|
||||||
m.sessionListIndex--
|
m.sessionListIndex--
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyDown || runeIs(k, "j"):
|
case k.Type == keyDown || runeIs(k, "j"):
|
||||||
if m.sessionListIndex < len(m.sessionList)-1 {
|
if m.sessionListIndex < len(m.sessionList)-1 {
|
||||||
m.sessionListIndex++
|
m.sessionListIndex++
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyEnter:
|
case k.Type == keyEnter:
|
||||||
if m.sessionListIndex >= 0 && m.sessionListIndex < len(m.sessionList) {
|
if m.sessionListIndex >= 0 && m.sessionListIndex < len(m.sessionList) {
|
||||||
return m.openSelectedSession()
|
return m.openSelectedSession()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package app
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func sessionsModel() Model {
|
func sessionsModel() Model {
|
||||||
@@ -25,7 +23,7 @@ func sampleSummaries() []SessionSummary {
|
|||||||
func TestSessionsOverlayToggle(t *testing.T) {
|
func TestSessionsOverlayToggle(t *testing.T) {
|
||||||
m := sessionsModel()
|
m := sessionsModel()
|
||||||
// `R` opens the browser from the idle list.
|
// `R` opens the browser from the idle list.
|
||||||
updated, _ := m.handleNormalKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("R")})
|
updated, _ := m.handleNormalKey(keyMsg{Type: keyRunes, Runes: []rune("R")})
|
||||||
m = updated.(Model)
|
m = updated.(Model)
|
||||||
if m.overlay != OverlaySessions {
|
if m.overlay != OverlaySessions {
|
||||||
t.Fatalf("expected OverlaySessions after R, got %d", m.overlay)
|
t.Fatalf("expected OverlaySessions after R, got %d", m.overlay)
|
||||||
@@ -34,7 +32,7 @@ func TestSessionsOverlayToggle(t *testing.T) {
|
|||||||
t.Fatalf("expected loading state right after open")
|
t.Fatalf("expected loading state right after open")
|
||||||
}
|
}
|
||||||
// `esc` closes it (routed through the overlay key handler).
|
// `esc` closes it (routed through the overlay key handler).
|
||||||
updated, _ = m.handleOverlayKey(tea.KeyMsg{Type: tea.KeyEsc})
|
updated, _ = m.handleOverlayKey(keyMsg{Type: keyEsc})
|
||||||
m = updated.(Model)
|
m = updated.(Model)
|
||||||
if m.overlay != OverlayNone {
|
if m.overlay != OverlayNone {
|
||||||
t.Fatalf("expected overlay closed after esc, got %d", m.overlay)
|
t.Fatalf("expected overlay closed after esc, got %d", m.overlay)
|
||||||
@@ -66,25 +64,25 @@ func TestSessionsNavigationBoundsChecked(t *testing.T) {
|
|||||||
m.overlay = OverlaySessions
|
m.overlay = OverlaySessions
|
||||||
m.sessionList = sampleSummaries()
|
m.sessionList = sampleSummaries()
|
||||||
// Up at the top is a no-op (clamped at 0).
|
// Up at the top is a no-op (clamped at 0).
|
||||||
updated, _ := m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyUp})
|
updated, _ := m.handleSessionsKey(keyMsg{Type: keyUp})
|
||||||
m = updated.(Model)
|
m = updated.(Model)
|
||||||
if m.sessionListIndex != 0 {
|
if m.sessionListIndex != 0 {
|
||||||
t.Fatalf("expected index clamped to 0 at top, got %d", m.sessionListIndex)
|
t.Fatalf("expected index clamped to 0 at top, got %d", m.sessionListIndex)
|
||||||
}
|
}
|
||||||
// Down moves to row 1.
|
// Down moves to row 1.
|
||||||
updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyDown})
|
updated, _ = m.handleSessionsKey(keyMsg{Type: keyDown})
|
||||||
m = updated.(Model)
|
m = updated.(Model)
|
||||||
if m.sessionListIndex != 1 {
|
if m.sessionListIndex != 1 {
|
||||||
t.Fatalf("expected index 1 after down, got %d", m.sessionListIndex)
|
t.Fatalf("expected index 1 after down, got %d", m.sessionListIndex)
|
||||||
}
|
}
|
||||||
// Down again is clamped at the last row.
|
// Down again is clamped at the last row.
|
||||||
updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyDown})
|
updated, _ = m.handleSessionsKey(keyMsg{Type: keyDown})
|
||||||
m = updated.(Model)
|
m = updated.(Model)
|
||||||
if m.sessionListIndex != 1 {
|
if m.sessionListIndex != 1 {
|
||||||
t.Fatalf("expected index clamped to 1 at bottom, got %d", m.sessionListIndex)
|
t.Fatalf("expected index clamped to 1 at bottom, got %d", m.sessionListIndex)
|
||||||
}
|
}
|
||||||
// `j` is the vim-style alias for down (still clamped here).
|
// `j` is the vim-style alias for down (still clamped here).
|
||||||
updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
|
updated, _ = m.handleSessionsKey(keyMsg{Type: keyRunes, Runes: []rune("j")})
|
||||||
m = updated.(Model)
|
m = updated.(Model)
|
||||||
if m.sessionListIndex != 1 {
|
if m.sessionListIndex != 1 {
|
||||||
t.Fatalf("expected j clamped at bottom, got %d", m.sessionListIndex)
|
t.Fatalf("expected j clamped at bottom, got %d", m.sessionListIndex)
|
||||||
@@ -96,7 +94,7 @@ func TestSessionsEnterFocusesAndEmitsResume(t *testing.T) {
|
|||||||
m.overlay = OverlaySessions
|
m.overlay = OverlaySessions
|
||||||
m.sessionList = sampleSummaries()
|
m.sessionList = sampleSummaries()
|
||||||
m.sessionListIndex = 1 // the FAILED healthcheck row
|
m.sessionListIndex = 1 // the FAILED healthcheck row
|
||||||
updated, cmd := m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyEnter})
|
updated, cmd := m.handleSessionsKey(keyMsg{Type: keyEnter})
|
||||||
m = updated.(Model)
|
m = updated.(Model)
|
||||||
if m.overlay != OverlayNone {
|
if m.overlay != OverlayNone {
|
||||||
t.Fatalf("expected overlay closed after resume, got %d", m.overlay)
|
t.Fatalf("expected overlay closed after resume, got %d", m.overlay)
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// `/` as the first char of a chat line opens the command palette inline (opencode-style),
|
// `/` as the first char of a chat line opens the command palette inline (opencode-style),
|
||||||
@@ -14,7 +12,7 @@ func TestSlashOpensPaletteOnEmptyLine(t *testing.T) {
|
|||||||
m.inputMode = ModeRouter
|
m.inputMode = ModeRouter
|
||||||
m.inputBuffer = ""
|
m.inputBuffer = ""
|
||||||
|
|
||||||
next, _ := m.handleInsertKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")})
|
next, _ := m.handleInsertKey(keyMsg{Type: keyRunes, Runes: []rune("/")})
|
||||||
nm := next.(Model)
|
nm := next.(Model)
|
||||||
|
|
||||||
if nm.overlay != OverlayPalette {
|
if nm.overlay != OverlayPalette {
|
||||||
@@ -33,7 +31,7 @@ func TestSlashLiteralMidLine(t *testing.T) {
|
|||||||
m.inputBuffer = "path"
|
m.inputBuffer = "path"
|
||||||
m.inputCursor = len("path")
|
m.inputCursor = len("path")
|
||||||
|
|
||||||
next, _ := m.handleInsertKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")})
|
next, _ := m.handleInsertKey(keyMsg{Type: keyRunes, Runes: []rune("/")})
|
||||||
nm := next.(Model)
|
nm := next.(Model)
|
||||||
|
|
||||||
if nm.overlay != OverlayNone {
|
if nm.overlay != OverlayNone {
|
||||||
|
|||||||
@@ -1,36 +1,40 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import "github.com/charmbracelet/lipgloss"
|
import (
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
)
|
||||||
|
|
||||||
// Palette is the "Soft" chrome from docs/visual/ref with the blue accent:
|
// Palette is the "Soft" chrome from docs/visual/ref with the blue accent:
|
||||||
// rounded borders, opaque dark panels, generous spacing.
|
// rounded borders, opaque dark panels, generous spacing.
|
||||||
type Palette struct {
|
type Palette struct {
|
||||||
Bg lipgloss.Color // panel/background fill
|
Bg color.Color // panel/background fill
|
||||||
BgDeep lipgloss.Color // outermost backdrop (slightly darker)
|
BgDeep color.Color // outermost backdrop (slightly darker)
|
||||||
BgPanel lipgloss.Color // inside-panel fill
|
BgPanel color.Color // inside-panel fill
|
||||||
Sel lipgloss.Color // selection row background
|
Sel color.Color // selection row background
|
||||||
Border lipgloss.Color // idle panel border
|
Border color.Color // idle panel border
|
||||||
BorderHi lipgloss.Color // active panel border
|
BorderHi color.Color // active panel border
|
||||||
Fg lipgloss.Color // primary text
|
Fg color.Color // primary text
|
||||||
FgStrong lipgloss.Color // headings / emphasis
|
FgStrong color.Color // headings / emphasis
|
||||||
Dim lipgloss.Color // labels, secondary text
|
Dim color.Color // labels, secondary text
|
||||||
Faint lipgloss.Color // tertiary, hints
|
Faint color.Color // tertiary, hints
|
||||||
Accent lipgloss.Color // blue accent
|
Accent color.Color // blue accent
|
||||||
Accent2 lipgloss.Color // secondary accent
|
Accent2 color.Color // secondary accent
|
||||||
|
|
||||||
OK lipgloss.Color
|
OK color.Color
|
||||||
Warn lipgloss.Color
|
Warn color.Color
|
||||||
Bad lipgloss.Color
|
Bad color.Color
|
||||||
|
|
||||||
DiffAdd lipgloss.Color // added-line cell background
|
DiffAdd color.Color // added-line cell background
|
||||||
DiffDel lipgloss.Color // removed-line cell background
|
DiffDel color.Color // removed-line cell background
|
||||||
|
|
||||||
CatLifecycle lipgloss.Color
|
CatLifecycle color.Color
|
||||||
CatContext lipgloss.Color
|
CatContext color.Color
|
||||||
CatInference lipgloss.Color
|
CatInference color.Color
|
||||||
CatTool lipgloss.Color
|
CatTool color.Color
|
||||||
CatDomain lipgloss.Color
|
CatDomain color.Color
|
||||||
CatApproval lipgloss.Color
|
CatApproval color.Color
|
||||||
}
|
}
|
||||||
|
|
||||||
// SoftBlue mirrors the reference Soft theme (#14161a bg, #ced3da fg) with the
|
// SoftBlue mirrors the reference Soft theme (#14161a bg, #ced3da fg) with the
|
||||||
@@ -91,7 +95,7 @@ type Theme struct {
|
|||||||
// NewTheme builds the style set for a palette.
|
// NewTheme builds the style set for a palette.
|
||||||
func NewTheme(p Palette) Theme {
|
func NewTheme(p Palette) Theme {
|
||||||
base := lipgloss.NewStyle().Background(p.Bg).Foreground(p.Fg)
|
base := lipgloss.NewStyle().Background(p.Bg).Foreground(p.Fg)
|
||||||
roundBase := func(border lipgloss.Color) lipgloss.Style {
|
roundBase := func(border color.Color) lipgloss.Style {
|
||||||
return lipgloss.NewStyle().
|
return lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(border).
|
BorderForeground(border).
|
||||||
@@ -128,7 +132,7 @@ func NewTheme(p Palette) Theme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// categoryColor maps an event category label to its accent color.
|
// categoryColor maps an event category label to its accent color.
|
||||||
func (t Theme) categoryColor(cat string) lipgloss.Color {
|
func (t Theme) categoryColor(cat string) color.Color {
|
||||||
switch cat {
|
switch cat {
|
||||||
case "Lifecycle":
|
case "Lifecycle":
|
||||||
return t.P.CatLifecycle
|
return t.P.CatLifecycle
|
||||||
|
|||||||
+104
-103
@@ -6,8 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
osc52 "github.com/aymanbagabas/go-osc52/v2"
|
osc52 "github.com/aymanbagabas/go-osc52/v2"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/correx/tui-go/internal/protocol"
|
"github.com/correx/tui-go/internal/protocol"
|
||||||
"github.com/correx/tui-go/internal/ws"
|
"github.com/correx/tui-go/internal/ws"
|
||||||
)
|
)
|
||||||
@@ -113,8 +113,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyPressMsg:
|
||||||
next, cmd := m.handleKey(msg)
|
next, cmd := m.handleKey(toKeyMsg(msg))
|
||||||
// A keypress may have entered a typing/active state (insert mode, steering, …);
|
// A keypress may have entered a typing/active state (insert mode, steering, …);
|
||||||
// kick the tick so the caret/spinner animates again.
|
// kick the tick so the caret/spinner animates again.
|
||||||
if nm, ok := next.(Model); ok {
|
if nm, ok := next.(Model); ok {
|
||||||
@@ -175,12 +175,12 @@ func (m *Model) applyServerPhased(msg protocol.ServerMessage) {
|
|||||||
|
|
||||||
// --- key handling ---
|
// --- key handling ---
|
||||||
|
|
||||||
func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) handleKey(k keyMsg) (tea.Model, tea.Cmd) {
|
||||||
debugLog("KEY type=%v runes=%q alt=%v | ds=%s input=%s edit=%s overlay=%d steering=%v steerBuf=%q dismissed=%v entered=%v sel=%s",
|
debugLog("KEY type=%v runes=%q alt=%v | ds=%s input=%s edit=%s overlay=%d steering=%v steerBuf=%q dismissed=%v entered=%v sel=%s",
|
||||||
k.Type, string(k.Runes), k.Alt, m.displayState(), m.inputMode, m.editMode, m.overlay,
|
k.Type, string(k.Runes), k.Alt, m.displayState(), m.inputMode, m.editMode, m.overlay,
|
||||||
m.steering, m.steerBuffer, m.approvalDismissed, m.sessionEntered, m.selectedID)
|
m.steering, m.steerBuffer, m.approvalDismissed, m.sessionEntered, m.selectedID)
|
||||||
// Ctrl+C is a universal hard-quit safety; everything else is bare-key modal.
|
// Ctrl+C is a universal hard-quit safety; everything else is bare-key modal.
|
||||||
if k.Type == tea.KeyCtrlC {
|
if k.Type == keyCtrlC {
|
||||||
m.quitting = true
|
m.quitting = true
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
@@ -198,17 +198,17 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
// back through your sent messages without leaving the input. ctrl+↑ older, ctrl+↓ newer.
|
// back through your sent messages without leaving the input. ctrl+↑ older, ctrl+↓ newer.
|
||||||
if m.displayState() == StateInSession {
|
if m.displayState() == StateInSession {
|
||||||
switch k.Type {
|
switch k.Type {
|
||||||
case tea.KeyCtrlUp:
|
case keyCtrlUp:
|
||||||
m.transcriptNavUser(-1)
|
m.transcriptNavUser(-1)
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyCtrlDown:
|
case keyCtrlDown:
|
||||||
m.transcriptNavUser(1)
|
m.transcriptNavUser(1)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// On the idle launcher, Tab cycles the launch target (chat → workflows → chat) in any
|
// On the idle launcher, Tab cycles the launch target (chat → workflows → chat) in any
|
||||||
// edit mode, so it works whether or not the input is focused.
|
// edit mode, so it works whether or not the input is focused.
|
||||||
if m.displayState() == StateIdle && k.Type == tea.KeyTab {
|
if m.displayState() == StateIdle && k.Type == keyTab {
|
||||||
m.cycleLauncherWf()
|
m.cycleLauncherWf()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -219,7 +219,7 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleNormalKey processes vim-style bare-key commands.
|
// handleNormalKey processes vim-style bare-key commands.
|
||||||
func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) handleNormalKey(k keyMsg) (tea.Model, tea.Cmd) {
|
||||||
ds := m.displayState()
|
ds := m.displayState()
|
||||||
// The approval band owns its keys: single-key y/n/e for low tiers, an arm→confirm
|
// The approval band owns its keys: single-key y/n/e for low tiers, an arm→confirm
|
||||||
// gate for T3+, and ↑/↓ to walk the pending queue. Handling it up front keeps the
|
// gate for T3+, and ↑/↓ to walk the pending queue. Handling it up front keeps the
|
||||||
@@ -228,15 +228,15 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m.handleApprovalKey(k)
|
return m.handleApprovalKey(k)
|
||||||
}
|
}
|
||||||
switch k.Type {
|
switch k.Type {
|
||||||
case tea.KeyUp:
|
case keyUp:
|
||||||
m.navUp()
|
m.navUp()
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyDown:
|
case keyDown:
|
||||||
m.navDown()
|
m.navDown()
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyEnter:
|
case keyEnter:
|
||||||
return m.normalEnter()
|
return m.normalEnter()
|
||||||
case tea.KeyEsc:
|
case keyEsc:
|
||||||
// In-session, esc first drops a transcript selection or output scroll (back to
|
// In-session, esc first drops a transcript selection or output scroll (back to
|
||||||
// tail-follow); otherwise it clears the session-list filter.
|
// tail-follow); otherwise it clears the session-list filter.
|
||||||
if m.transcriptSel >= 0 {
|
if m.transcriptSel >= 0 {
|
||||||
@@ -249,26 +249,26 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.filter = ""
|
m.filter = ""
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyPgUp:
|
case keyPgUp:
|
||||||
m.scrollOutput(m.outputViewportPage())
|
m.scrollOutput(m.outputViewportPage())
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyPgDown:
|
case keyPgDown:
|
||||||
m.scrollOutput(-m.outputViewportPage())
|
m.scrollOutput(-m.outputViewportPage())
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyCtrlU:
|
case keyCtrlU:
|
||||||
m.scrollOutput(m.outputViewportPage() / 2)
|
m.scrollOutput(m.outputViewportPage() / 2)
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyCtrlD:
|
case keyCtrlD:
|
||||||
m.scrollOutput(-m.outputViewportPage() / 2)
|
m.scrollOutput(-m.outputViewportPage() / 2)
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyCtrlX:
|
case keyCtrlX:
|
||||||
if m.currentDiff() != "" {
|
if m.currentDiff() != "" {
|
||||||
m.overlay = OverlayDiff
|
m.overlay = OverlayDiff
|
||||||
m.diffScrollOffset = 0
|
m.diffScrollOffset = 0
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if k.Type != tea.KeyRunes {
|
if k.Type != keyRunes {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
switch string(k.Runes) {
|
switch string(k.Runes) {
|
||||||
@@ -350,18 +350,18 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
// handleApprovalKey owns the approval band's bare keys. Low tiers (T0–T2) act on a
|
// handleApprovalKey owns the approval band's bare keys. Low tiers (T0–T2) act on a
|
||||||
// single keypress; a T3+ approve arms first and only a second confirming key sends
|
// single keypress; a T3+ approve arms first and only a second confirming key sends
|
||||||
// it (any other key disarms). ↑/↓ walk the pending queue without resolving anything.
|
// it (any other key disarms). ↑/↓ walk the pending queue without resolving anything.
|
||||||
func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) handleApprovalKey(k keyMsg) (tea.Model, tea.Cmd) {
|
||||||
s := m.session(m.selectedID)
|
s := m.session(m.selectedID)
|
||||||
if s == nil || s.Pending == nil {
|
if s == nil || s.Pending == nil {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
// Queue navigation never resolves a gate; it just changes which one is shown.
|
// Queue navigation never resolves a gate; it just changes which one is shown.
|
||||||
switch {
|
switch {
|
||||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
case k.Type == keyUp || runeIs(k, "k"):
|
||||||
s.navApproval(-1)
|
s.navApproval(-1)
|
||||||
m.approvalArmed = false
|
m.approvalArmed = false
|
||||||
return m, nil
|
return m, nil
|
||||||
case k.Type == tea.KeyDown || runeIs(k, "j"):
|
case k.Type == keyDown || runeIs(k, "j"):
|
||||||
s.navApproval(1)
|
s.navApproval(1)
|
||||||
m.approvalArmed = false
|
m.approvalArmed = false
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -369,7 +369,7 @@ func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
// Approve: y / a / enter / ^a. For a high tier this arms the confirm instead of
|
// Approve: y / a / enter / ^a. For a high tier this arms the confirm instead of
|
||||||
// sending; the same key pressed again (while armed) confirms.
|
// sending; the same key pressed again (while armed) confirms.
|
||||||
approveKey := k.Type == tea.KeyEnter || k.Type == tea.KeyCtrlA ||
|
approveKey := k.Type == keyEnter || k.Type == keyCtrlA ||
|
||||||
runeIs(k, "y") || runeIs(k, "a")
|
runeIs(k, "y") || runeIs(k, "a")
|
||||||
if approveKey {
|
if approveKey {
|
||||||
if s.Pending.HighTier() && !m.approvalArmed {
|
if s.Pending.HighTier() && !m.approvalArmed {
|
||||||
@@ -383,7 +383,7 @@ func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.approvalArmed = false
|
m.approvalArmed = false
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case k.Type == tea.KeyCtrlR || runeIs(k, "n") || runeIs(k, "r"):
|
case k.Type == keyCtrlR || runeIs(k, "n") || runeIs(k, "r"):
|
||||||
return m.decide("REJECT")
|
return m.decide("REJECT")
|
||||||
case runeIs(k, "e") || runeIs(k, "s"):
|
case runeIs(k, "e") || runeIs(k, "s"):
|
||||||
// Steer/edit flows into the existing steering-note input path.
|
// Steer/edit flows into the existing steering-note input path.
|
||||||
@@ -396,10 +396,10 @@ func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.sessionEntered = false
|
m.sessionEntered = false
|
||||||
m.approvalDismissed = false
|
m.approvalDismissed = false
|
||||||
return m, nil
|
return m, nil
|
||||||
case k.Type == tea.KeyEsc:
|
case k.Type == keyEsc:
|
||||||
m.approvalDismissed = true
|
m.approvalDismissed = true
|
||||||
return m, nil
|
return m, nil
|
||||||
case k.Type == tea.KeyCtrlX:
|
case k.Type == keyCtrlX:
|
||||||
if m.currentDiff() != "" {
|
if m.currentDiff() != "" {
|
||||||
m.overlay = OverlayDiff
|
m.overlay = OverlayDiff
|
||||||
m.diffScrollOffset = 0
|
m.diffScrollOffset = 0
|
||||||
@@ -414,26 +414,26 @@ func (m Model) handleApprovalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleInsertKey processes typing (chat/filter/steer note).
|
// handleInsertKey processes typing (chat/filter/steer note).
|
||||||
func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) handleInsertKey(k keyMsg) (tea.Model, tea.Cmd) {
|
||||||
if m.steering {
|
if m.steering {
|
||||||
switch k.Type {
|
switch k.Type {
|
||||||
case tea.KeyEsc:
|
case keyEsc:
|
||||||
m.steering = false
|
m.steering = false
|
||||||
m.editMode = ModeNormal
|
m.editMode = ModeNormal
|
||||||
case tea.KeyEnter:
|
case keyEnter:
|
||||||
return m.submitApproval()
|
return m.submitApproval()
|
||||||
case tea.KeyBackspace:
|
case keyBackspace:
|
||||||
if n := len(m.steerBuffer); n > 0 {
|
if n := len(m.steerBuffer); n > 0 {
|
||||||
m.steerBuffer = m.steerBuffer[:n-1]
|
m.steerBuffer = m.steerBuffer[:n-1]
|
||||||
}
|
}
|
||||||
case tea.KeyRunes, tea.KeySpace:
|
case keyRunes, keySpace:
|
||||||
m.steerBuffer += string(k.Runes)
|
m.steerBuffer += string(k.Runes)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch k.Type {
|
switch k.Type {
|
||||||
case tea.KeyEsc:
|
case keyEsc:
|
||||||
m.editMode = ModeNormal
|
m.editMode = ModeNormal
|
||||||
if m.inputMode == ModeFilter {
|
if m.inputMode == ModeFilter {
|
||||||
m.inputMode = ModeRouter
|
m.inputMode = ModeRouter
|
||||||
@@ -444,45 +444,46 @@ func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.clearInput()
|
m.clearInput()
|
||||||
m.inputMode = ModeRouter
|
m.inputMode = ModeRouter
|
||||||
}
|
}
|
||||||
case tea.KeyEnter:
|
case keyEnter:
|
||||||
// Alt+Enter inserts a newline; a bare Enter submits. (Shift+Enter is not
|
// Shift+Enter (and Alt+Enter) insert a newline; a bare Enter submits. Shift+Enter
|
||||||
// distinguishable from Enter in this terminal stack — see Ctrl+J below.)
|
// is now distinguishable thanks to bubbletea v2's kitty keyboard disambiguation;
|
||||||
if k.Alt && m.inputMode != ModeFilter {
|
// Ctrl+J below stays as a fallback for terminals without kitty support.
|
||||||
|
if (k.Shift || k.Alt) && m.inputMode != ModeFilter {
|
||||||
m.appendRunes("\n")
|
m.appendRunes("\n")
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
return m.submit()
|
return m.submit()
|
||||||
case tea.KeyCtrlJ:
|
case keyCtrlJ:
|
||||||
// Reliable "newline without sending" (Ctrl+J is the literal LF key) for the
|
// Reliable "newline without sending" (Ctrl+J is the literal LF key) for the
|
||||||
// single-line filter excluded.
|
// single-line filter excluded.
|
||||||
if m.inputMode != ModeFilter {
|
if m.inputMode != ModeFilter {
|
||||||
m.appendRunes("\n")
|
m.appendRunes("\n")
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyBackspace:
|
case keyBackspace:
|
||||||
m.backspace()
|
m.backspace()
|
||||||
m.syncFilter()
|
m.syncFilter()
|
||||||
case tea.KeyLeft:
|
case keyLeft:
|
||||||
if m.inputCursor > 0 {
|
if m.inputCursor > 0 {
|
||||||
m.inputCursor--
|
m.inputCursor--
|
||||||
}
|
}
|
||||||
case tea.KeyRight:
|
case keyRight:
|
||||||
if m.inputCursor < len(m.inputBuffer) {
|
if m.inputCursor < len(m.inputBuffer) {
|
||||||
m.inputCursor++
|
m.inputCursor++
|
||||||
}
|
}
|
||||||
case tea.KeyUp:
|
case keyUp:
|
||||||
if m.inputMode == ModeFilter {
|
if m.inputMode == ModeFilter {
|
||||||
m.listNav(-1)
|
m.listNav(-1)
|
||||||
} else {
|
} else {
|
||||||
m.historyPrev()
|
m.historyPrev()
|
||||||
}
|
}
|
||||||
case tea.KeyDown:
|
case keyDown:
|
||||||
if m.inputMode == ModeFilter {
|
if m.inputMode == ModeFilter {
|
||||||
m.listNav(1)
|
m.listNav(1)
|
||||||
} else {
|
} else {
|
||||||
m.historyNext()
|
m.historyNext()
|
||||||
}
|
}
|
||||||
case tea.KeyRunes, tea.KeySpace:
|
case keyRunes, keySpace:
|
||||||
// opencode-style slash commands: `/` as the first char of a chat line opens the
|
// opencode-style slash commands: `/` as the first char of a chat line opens the
|
||||||
// command palette inline instead of typing a literal slash. Mid-line `/` is literal.
|
// command palette inline instead of typing a literal slash. Mid-line `/` is literal.
|
||||||
if m.inputMode == ModeRouter && m.inputBuffer == "" && string(k.Runes) == "/" {
|
if m.inputMode == ModeRouter && m.inputBuffer == "" && string(k.Runes) == "/" {
|
||||||
@@ -627,7 +628,7 @@ func (m Model) autoApprove() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) handleOverlayKey(k keyMsg) (tea.Model, tea.Cmd) {
|
||||||
// Config overlay owns all its keys (esc cancels an in-progress edit rather than closing).
|
// Config overlay owns all its keys (esc cancels an in-progress edit rather than closing).
|
||||||
if m.overlay == OverlayConfig {
|
if m.overlay == OverlayConfig {
|
||||||
return m.handleConfigKey(k)
|
return m.handleConfigKey(k)
|
||||||
@@ -637,7 +638,7 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
if m.overlay == OverlaySessions {
|
if m.overlay == OverlaySessions {
|
||||||
return m.handleSessionsKey(k)
|
return m.handleSessionsKey(k)
|
||||||
}
|
}
|
||||||
if k.Type == tea.KeyEsc {
|
if k.Type == keyEsc {
|
||||||
// In the event inspector, esc first stops an in-progress filter edit (keeping the
|
// In the event inspector, esc first stops an in-progress filter edit (keeping the
|
||||||
// query) rather than closing the overlay.
|
// query) rather than closing the overlay.
|
||||||
if m.overlay == OverlayEventInspector && m.eventFilterTyping {
|
if m.overlay == OverlayEventInspector && m.eventFilterTyping {
|
||||||
@@ -656,37 +657,37 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
// full body, ^u/^d by half, g/G to the ends. All clamp to the diff bounds.
|
// full body, ^u/^d by half, g/G to the ends. All clamp to the diff bounds.
|
||||||
page := m.diffBodyHeight()
|
page := m.diffBodyHeight()
|
||||||
switch {
|
switch {
|
||||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
case k.Type == keyUp || runeIs(k, "k"):
|
||||||
m.scrollDiff(-1)
|
m.scrollDiff(-1)
|
||||||
case k.Type == tea.KeyDown || runeIs(k, "j"):
|
case k.Type == keyDown || runeIs(k, "j"):
|
||||||
m.scrollDiff(1)
|
m.scrollDiff(1)
|
||||||
case k.Type == tea.KeyPgUp:
|
case k.Type == keyPgUp:
|
||||||
m.scrollDiff(-page)
|
m.scrollDiff(-page)
|
||||||
case k.Type == tea.KeyPgDown:
|
case k.Type == keyPgDown:
|
||||||
m.scrollDiff(page)
|
m.scrollDiff(page)
|
||||||
case k.Type == tea.KeyCtrlU:
|
case k.Type == keyCtrlU:
|
||||||
m.scrollDiff(-page / 2)
|
m.scrollDiff(-page / 2)
|
||||||
case k.Type == tea.KeyCtrlD:
|
case k.Type == keyCtrlD:
|
||||||
m.scrollDiff(page / 2)
|
m.scrollDiff(page / 2)
|
||||||
case runeIs(k, "g"):
|
case runeIs(k, "g"):
|
||||||
m.diffScrollOffset = 0
|
m.diffScrollOffset = 0
|
||||||
case runeIs(k, "G"):
|
case runeIs(k, "G"):
|
||||||
m.diffScrollOffset = m.diffMaxScroll()
|
m.diffScrollOffset = m.diffMaxScroll()
|
||||||
case k.Type == tea.KeyCtrlX:
|
case k.Type == keyCtrlX:
|
||||||
m.overlay = OverlayNone
|
m.overlay = OverlayNone
|
||||||
}
|
}
|
||||||
case OverlayEventInspector:
|
case OverlayEventInspector:
|
||||||
evs := m.currentEvents()
|
evs := m.currentEvents()
|
||||||
if m.eventFilterTyping {
|
if m.eventFilterTyping {
|
||||||
switch {
|
switch {
|
||||||
case k.Type == tea.KeyEnter:
|
case k.Type == keyEnter:
|
||||||
m.eventFilterTyping = false
|
m.eventFilterTyping = false
|
||||||
case k.Type == tea.KeyBackspace:
|
case k.Type == keyBackspace:
|
||||||
if n := len(m.eventFilter); n > 0 {
|
if n := len(m.eventFilter); n > 0 {
|
||||||
m.eventFilter = m.eventFilter[:n-1]
|
m.eventFilter = m.eventFilter[:n-1]
|
||||||
m.overlayEventIdx = 0
|
m.overlayEventIdx = 0
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyRunes || k.Type == tea.KeySpace:
|
case k.Type == keyRunes || k.Type == keySpace:
|
||||||
m.eventFilter += string(k.Runes)
|
m.eventFilter += string(k.Runes)
|
||||||
m.overlayEventIdx = 0
|
m.overlayEventIdx = 0
|
||||||
}
|
}
|
||||||
@@ -698,11 +699,11 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case runeIs(k, "c"):
|
case runeIs(k, "c"):
|
||||||
m.eventFilter = ""
|
m.eventFilter = ""
|
||||||
m.overlayEventIdx = 0
|
m.overlayEventIdx = 0
|
||||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
case k.Type == keyUp || runeIs(k, "k"):
|
||||||
if m.overlayEventIdx > 0 {
|
if m.overlayEventIdx > 0 {
|
||||||
m.overlayEventIdx--
|
m.overlayEventIdx--
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyDown || runeIs(k, "j"):
|
case k.Type == keyDown || runeIs(k, "j"):
|
||||||
if m.overlayEventIdx < len(evs)-1 {
|
if m.overlayEventIdx < len(evs)-1 {
|
||||||
m.overlayEventIdx++
|
m.overlayEventIdx++
|
||||||
}
|
}
|
||||||
@@ -712,17 +713,17 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
// dismisses it (esc handled above), keeping the quick-glance feel.
|
// dismisses it (esc handled above), keeping the quick-glance feel.
|
||||||
page := m.modalBodyRows()
|
page := m.modalBodyRows()
|
||||||
switch {
|
switch {
|
||||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
case k.Type == keyUp || runeIs(k, "k"):
|
||||||
m.scrollModal(-1)
|
m.scrollModal(-1)
|
||||||
case k.Type == tea.KeyDown || runeIs(k, "j"):
|
case k.Type == keyDown || runeIs(k, "j"):
|
||||||
m.scrollModal(1)
|
m.scrollModal(1)
|
||||||
case k.Type == tea.KeyPgUp:
|
case k.Type == keyPgUp:
|
||||||
m.scrollModal(-page)
|
m.scrollModal(-page)
|
||||||
case k.Type == tea.KeyPgDown:
|
case k.Type == keyPgDown:
|
||||||
m.scrollModal(page)
|
m.scrollModal(page)
|
||||||
case k.Type == tea.KeyCtrlU:
|
case k.Type == keyCtrlU:
|
||||||
m.scrollModal(-page / 2)
|
m.scrollModal(-page / 2)
|
||||||
case k.Type == tea.KeyCtrlD:
|
case k.Type == keyCtrlD:
|
||||||
m.scrollModal(page / 2)
|
m.scrollModal(page / 2)
|
||||||
case runeIs(k, "g"):
|
case runeIs(k, "g"):
|
||||||
m.modalScroll = 0
|
m.modalScroll = 0
|
||||||
@@ -737,23 +738,23 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
case OverlayArtifacts:
|
case OverlayArtifacts:
|
||||||
switch {
|
switch {
|
||||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
case k.Type == keyUp || runeIs(k, "k"):
|
||||||
if m.artifactsIndex > 0 {
|
if m.artifactsIndex > 0 {
|
||||||
m.artifactsIndex--
|
m.artifactsIndex--
|
||||||
m.artifactScroll = 0
|
m.artifactScroll = 0
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyDown || runeIs(k, "j"):
|
case k.Type == keyDown || runeIs(k, "j"):
|
||||||
if m.artifactsIndex < len(m.artifacts)-1 {
|
if m.artifactsIndex < len(m.artifacts)-1 {
|
||||||
m.artifactsIndex++
|
m.artifactsIndex++
|
||||||
m.artifactScroll = 0
|
m.artifactScroll = 0
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyPgUp:
|
case k.Type == keyPgUp:
|
||||||
m.scrollArtifact(-m.artifactContentBodyH())
|
m.scrollArtifact(-m.artifactContentBodyH())
|
||||||
case k.Type == tea.KeyPgDown:
|
case k.Type == keyPgDown:
|
||||||
m.scrollArtifact(m.artifactContentBodyH())
|
m.scrollArtifact(m.artifactContentBodyH())
|
||||||
case k.Type == tea.KeyCtrlU:
|
case k.Type == keyCtrlU:
|
||||||
m.scrollArtifact(-m.artifactContentBodyH() / 2)
|
m.scrollArtifact(-m.artifactContentBodyH() / 2)
|
||||||
case k.Type == tea.KeyCtrlD:
|
case k.Type == keyCtrlD:
|
||||||
m.scrollArtifact(m.artifactContentBodyH() / 2)
|
m.scrollArtifact(m.artifactContentBodyH() / 2)
|
||||||
case runeIs(k, "g"):
|
case runeIs(k, "g"):
|
||||||
m.artifactScroll = 0
|
m.artifactScroll = 0
|
||||||
@@ -764,15 +765,15 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
case OverlayModels:
|
case OverlayModels:
|
||||||
switch {
|
switch {
|
||||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
case k.Type == keyUp || runeIs(k, "k"):
|
||||||
if m.modelsIndex > 0 {
|
if m.modelsIndex > 0 {
|
||||||
m.modelsIndex--
|
m.modelsIndex--
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyDown || runeIs(k, "j"):
|
case k.Type == keyDown || runeIs(k, "j"):
|
||||||
if m.modelsIndex < len(m.availableModels)-1 {
|
if m.modelsIndex < len(m.availableModels)-1 {
|
||||||
m.modelsIndex++
|
m.modelsIndex++
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyEnter:
|
case k.Type == keyEnter:
|
||||||
if m.modelsIndex >= 0 && m.modelsIndex < len(m.availableModels) {
|
if m.modelsIndex >= 0 && m.modelsIndex < len(m.availableModels) {
|
||||||
m.client.Send(protocol.SwapModel(m.availableModels[m.modelsIndex]))
|
m.client.Send(protocol.SwapModel(m.availableModels[m.modelsIndex]))
|
||||||
m.overlay = OverlayNone
|
m.overlay = OverlayNone
|
||||||
@@ -786,17 +787,17 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case OverlayStats:
|
case OverlayStats:
|
||||||
page := m.modalBodyRows()
|
page := m.modalBodyRows()
|
||||||
switch {
|
switch {
|
||||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
case k.Type == keyUp || runeIs(k, "k"):
|
||||||
m.scrollModal(-1)
|
m.scrollModal(-1)
|
||||||
case k.Type == tea.KeyDown || runeIs(k, "j"):
|
case k.Type == keyDown || runeIs(k, "j"):
|
||||||
m.scrollModal(1)
|
m.scrollModal(1)
|
||||||
case k.Type == tea.KeyPgUp:
|
case k.Type == keyPgUp:
|
||||||
m.scrollModal(-page)
|
m.scrollModal(-page)
|
||||||
case k.Type == tea.KeyPgDown:
|
case k.Type == keyPgDown:
|
||||||
m.scrollModal(page)
|
m.scrollModal(page)
|
||||||
case k.Type == tea.KeyCtrlU:
|
case k.Type == keyCtrlU:
|
||||||
m.scrollModal(-page / 2)
|
m.scrollModal(-page / 2)
|
||||||
case k.Type == tea.KeyCtrlD:
|
case k.Type == keyCtrlD:
|
||||||
m.scrollModal(page / 2)
|
m.scrollModal(page / 2)
|
||||||
case runeIs(k, "g"):
|
case runeIs(k, "g"):
|
||||||
m.modalScroll = 0
|
m.modalScroll = 0
|
||||||
@@ -807,11 +808,11 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
case OverlayIdeas:
|
case OverlayIdeas:
|
||||||
switch {
|
switch {
|
||||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
case k.Type == keyUp || runeIs(k, "k"):
|
||||||
if m.ideasIndex > 0 {
|
if m.ideasIndex > 0 {
|
||||||
m.ideasIndex--
|
m.ideasIndex--
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyDown || runeIs(k, "j"):
|
case k.Type == keyDown || runeIs(k, "j"):
|
||||||
if m.ideasIndex < len(m.ideas)-1 {
|
if m.ideasIndex < len(m.ideas)-1 {
|
||||||
m.ideasIndex++
|
m.ideasIndex++
|
||||||
}
|
}
|
||||||
@@ -830,39 +831,39 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case OverlayFiles:
|
case OverlayFiles:
|
||||||
files := m.filteredFiles()
|
files := m.filteredFiles()
|
||||||
switch {
|
switch {
|
||||||
case k.Type == tea.KeyUp:
|
case k.Type == keyUp:
|
||||||
if m.fileIndex > 0 {
|
if m.fileIndex > 0 {
|
||||||
m.fileIndex--
|
m.fileIndex--
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyDown:
|
case k.Type == keyDown:
|
||||||
if m.fileIndex < len(files)-1 {
|
if m.fileIndex < len(files)-1 {
|
||||||
m.fileIndex++
|
m.fileIndex++
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyEnter:
|
case k.Type == keyEnter:
|
||||||
if m.fileIndex >= 0 && m.fileIndex < len(files) {
|
if m.fileIndex >= 0 && m.fileIndex < len(files) {
|
||||||
m.insertFileRef(files[m.fileIndex])
|
m.insertFileRef(files[m.fileIndex])
|
||||||
}
|
}
|
||||||
m.overlay = OverlayNone // back to typing (still in insert mode)
|
m.overlay = OverlayNone // back to typing (still in insert mode)
|
||||||
case k.Type == tea.KeyBackspace:
|
case k.Type == keyBackspace:
|
||||||
if n := len(m.fileFilter); n > 0 {
|
if n := len(m.fileFilter); n > 0 {
|
||||||
m.fileFilter = m.fileFilter[:n-1]
|
m.fileFilter = m.fileFilter[:n-1]
|
||||||
m.fileIndex = 0
|
m.fileIndex = 0
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyRunes || k.Type == tea.KeySpace:
|
case k.Type == keyRunes || k.Type == keySpace:
|
||||||
m.fileFilter += string(k.Runes)
|
m.fileFilter += string(k.Runes)
|
||||||
m.fileIndex = 0
|
m.fileIndex = 0
|
||||||
}
|
}
|
||||||
case OverlayStatusbar:
|
case OverlayStatusbar:
|
||||||
switch {
|
switch {
|
||||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
case k.Type == keyUp || runeIs(k, "k"):
|
||||||
if m.sbIndex > 0 {
|
if m.sbIndex > 0 {
|
||||||
m.sbIndex--
|
m.sbIndex--
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyDown || runeIs(k, "j"):
|
case k.Type == keyDown || runeIs(k, "j"):
|
||||||
if m.sbIndex < len(statusSegments)-1 {
|
if m.sbIndex < len(statusSegments)-1 {
|
||||||
m.sbIndex++
|
m.sbIndex++
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeySpace || k.Type == tea.KeyEnter || runeIs(k, "x"):
|
case k.Type == keySpace || k.Type == keyEnter || runeIs(k, "x"):
|
||||||
if m.sbIndex >= 0 && m.sbIndex < len(statusSegments) {
|
if m.sbIndex >= 0 && m.sbIndex < len(statusSegments) {
|
||||||
m.toggleStatusSegment(statusSegments[m.sbIndex].id)
|
m.toggleStatusSegment(statusSegments[m.sbIndex].id)
|
||||||
}
|
}
|
||||||
@@ -871,15 +872,15 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
case OverlayGrants:
|
case OverlayGrants:
|
||||||
switch {
|
switch {
|
||||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
case k.Type == keyUp || runeIs(k, "k"):
|
||||||
if m.grantIndex > 0 {
|
if m.grantIndex > 0 {
|
||||||
m.grantIndex--
|
m.grantIndex--
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyDown || runeIs(k, "j"):
|
case k.Type == keyDown || runeIs(k, "j"):
|
||||||
if m.grantIndex < len(m.grants)-1 {
|
if m.grantIndex < len(m.grants)-1 {
|
||||||
m.grantIndex++
|
m.grantIndex++
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyEnter || runeIs(k, "x"):
|
case k.Type == keyEnter || runeIs(k, "x"):
|
||||||
if m.grantIndex >= 0 && m.grantIndex < len(m.grants) {
|
if m.grantIndex >= 0 && m.grantIndex < len(m.grants) {
|
||||||
g := m.grants[m.grantIndex]
|
g := m.grants[m.grantIndex]
|
||||||
m.client.Send(protocol.RevokeGrant(g.GrantID))
|
m.client.Send(protocol.RevokeGrant(g.GrantID))
|
||||||
@@ -910,14 +911,14 @@ func grantScopeOptions() []grantScopeOption {
|
|||||||
|
|
||||||
// handleGrantScopeKey drives the scope picker: ↑/↓ move, s/p/g jump-and-confirm, enter
|
// handleGrantScopeKey drives the scope picker: ↑/↓ move, s/p/g jump-and-confirm, enter
|
||||||
// confirms the highlighted scope. esc (handled upstream) cancels without granting.
|
// confirms the highlighted scope. esc (handled upstream) cancels without granting.
|
||||||
func (m Model) handleGrantScopeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) handleGrantScopeKey(k keyMsg) (tea.Model, tea.Cmd) {
|
||||||
opts := grantScopeOptions()
|
opts := grantScopeOptions()
|
||||||
switch {
|
switch {
|
||||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
case k.Type == keyUp || runeIs(k, "k"):
|
||||||
if m.grantScopeIndex > 0 {
|
if m.grantScopeIndex > 0 {
|
||||||
m.grantScopeIndex--
|
m.grantScopeIndex--
|
||||||
}
|
}
|
||||||
case k.Type == tea.KeyDown || runeIs(k, "j"):
|
case k.Type == keyDown || runeIs(k, "j"):
|
||||||
if m.grantScopeIndex < len(opts)-1 {
|
if m.grantScopeIndex < len(opts)-1 {
|
||||||
m.grantScopeIndex++
|
m.grantScopeIndex++
|
||||||
}
|
}
|
||||||
@@ -930,7 +931,7 @@ func (m Model) handleGrantScopeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case runeIs(k, "g"):
|
case runeIs(k, "g"):
|
||||||
m.grantScopeIndex = 2
|
m.grantScopeIndex = 2
|
||||||
return m.applyGrantScope()
|
return m.applyGrantScope()
|
||||||
case k.Type == tea.KeyEnter:
|
case k.Type == keyEnter:
|
||||||
return m.applyGrantScope()
|
return m.applyGrantScope()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -1046,31 +1047,31 @@ func (m *Model) openModelsOverlay() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runeIs(k tea.KeyMsg, s string) bool {
|
func runeIs(k keyMsg, s string) bool {
|
||||||
return k.Type == tea.KeyRunes && string(k.Runes) == s
|
return k.Type == keyRunes && string(k.Runes) == s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) handlePaletteKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) handlePaletteKey(k keyMsg) (tea.Model, tea.Cmd) {
|
||||||
cmds := m.filteredPalette()
|
cmds := m.filteredPalette()
|
||||||
switch k.Type {
|
switch k.Type {
|
||||||
case tea.KeyUp:
|
case keyUp:
|
||||||
if m.paletteIndex > 0 {
|
if m.paletteIndex > 0 {
|
||||||
m.paletteIndex--
|
m.paletteIndex--
|
||||||
}
|
}
|
||||||
case tea.KeyDown:
|
case keyDown:
|
||||||
if m.paletteIndex < len(cmds)-1 {
|
if m.paletteIndex < len(cmds)-1 {
|
||||||
m.paletteIndex++
|
m.paletteIndex++
|
||||||
}
|
}
|
||||||
case tea.KeyEnter:
|
case keyEnter:
|
||||||
if m.paletteIndex >= 0 && m.paletteIndex < len(cmds) {
|
if m.paletteIndex >= 0 && m.paletteIndex < len(cmds) {
|
||||||
return m.execPalette(cmds[m.paletteIndex].id)
|
return m.execPalette(cmds[m.paletteIndex].id)
|
||||||
}
|
}
|
||||||
case tea.KeyBackspace:
|
case keyBackspace:
|
||||||
if n := len(m.paletteFilter); n > 0 {
|
if n := len(m.paletteFilter); n > 0 {
|
||||||
m.paletteFilter = m.paletteFilter[:n-1]
|
m.paletteFilter = m.paletteFilter[:n-1]
|
||||||
m.paletteIndex = 0
|
m.paletteIndex = 0
|
||||||
}
|
}
|
||||||
case tea.KeyRunes, tea.KeySpace:
|
case keyRunes, keySpace:
|
||||||
m.paletteFilter += string(k.Runes)
|
m.paletteFilter += string(k.Runes)
|
||||||
m.paletteIndex = 0
|
m.paletteIndex = 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image/color"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/charmbracelet/x/ansi"
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,8 +30,15 @@ const (
|
|||||||
// and full-width chrome.
|
// and full-width chrome.
|
||||||
func (m Model) narrow() bool { return m.width < narrowWidth }
|
func (m Model) narrow() bool { return m.width < narrowWidth }
|
||||||
|
|
||||||
// View renders the whole frame purely from the model (immediate-mode).
|
// View renders the whole frame purely from the model (immediate-mode). In
|
||||||
func (m Model) View() string {
|
// bubbletea v2 the View carries terminal state (alt-screen, keyboard mode), so
|
||||||
|
// render() builds the string and View() wraps it with the screen settings.
|
||||||
|
func (m Model) View() tea.View {
|
||||||
|
return tea.View{Content: m.render(), AltScreen: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// render builds the frame string.
|
||||||
|
func (m Model) render() string {
|
||||||
if m.quitting {
|
if m.quitting {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -79,7 +88,7 @@ func (m Model) View() string {
|
|||||||
func (m Model) renderStatus() string {
|
func (m Model) renderStatus() string {
|
||||||
t := m.theme
|
t := m.theme
|
||||||
bg := t.P.BgPanel
|
bg := t.P.BgPanel
|
||||||
span := func(s string, fg lipgloss.Color) string {
|
span := func(s string, fg color.Color) string {
|
||||||
return lipgloss.NewStyle().Foreground(fg).Background(bg).Render(s)
|
return lipgloss.NewStyle().Foreground(fg).Background(bg).Render(s)
|
||||||
}
|
}
|
||||||
nrw := m.narrow()
|
nrw := m.narrow()
|
||||||
@@ -376,12 +385,12 @@ func (m Model) renderLauncher(w, h int) string {
|
|||||||
inW = 24
|
inW = 24
|
||||||
}
|
}
|
||||||
left := lipgloss.Place(leftW, h, lipgloss.Center, lipgloss.Center, m.launcherInput(inW),
|
left := lipgloss.Place(leftW, h, lipgloss.Center, lipgloss.Center, m.launcherInput(inW),
|
||||||
lipgloss.WithWhitespaceBackground(t.P.Bg))
|
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.P.Bg)))
|
||||||
if railW == 0 {
|
if railW == 0 {
|
||||||
return left
|
return left
|
||||||
}
|
}
|
||||||
rail := lipgloss.Place(railW, h, lipgloss.Left, lipgloss.Top, m.launcherRail(railW),
|
rail := lipgloss.Place(railW, h, lipgloss.Left, lipgloss.Top, m.launcherRail(railW),
|
||||||
lipgloss.WithWhitespaceBackground(t.P.Bg))
|
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.P.Bg)))
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, rail)
|
return lipgloss.JoinHorizontal(lipgloss.Top, left, rail)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,7 +747,7 @@ func (m Model) eventStreamBadge() string {
|
|||||||
return t.span("○ idle", t.P.Faint)
|
return t.span("○ idle", t.P.Faint)
|
||||||
}
|
}
|
||||||
if s := m.session(m.selectedID); s != nil {
|
if s := m.session(m.selectedID); s != nil {
|
||||||
badge := func(fg lipgloss.Color, text string) string {
|
badge := func(fg color.Color, text string) string {
|
||||||
return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(text)
|
return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(text)
|
||||||
}
|
}
|
||||||
switch u := strings.ToUpper(s.Status); {
|
switch u := strings.ToUpper(s.Status); {
|
||||||
@@ -764,7 +773,7 @@ func shortPath(p string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fill left-justifies a styled line and pads with bg to width w.
|
// fill left-justifies a styled line and pads with bg to width w.
|
||||||
func (m Model) fill(s string, w int, bg lipgloss.Color) string {
|
func (m Model) fill(s string, w int, bg color.Color) string {
|
||||||
return " " + padTo(s, w-1, bg)
|
return " " + padTo(s, w-1, bg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,7 +781,7 @@ func (m Model) fill(s string, w int, bg lipgloss.Color) string {
|
|||||||
// so the line never overflows w. The left segment is shrunk first (the right holds
|
// so the line never overflows w. The left segment is shrunk first (the right holds
|
||||||
// the high-signal status — connection clock / active spinner); the right is clamped
|
// the high-signal status — connection clock / active spinner); the right is clamped
|
||||||
// only as a last resort. ANSI-aware so truncation can't slice through escape codes.
|
// only as a last resort. ANSI-aware so truncation can't slice through escape codes.
|
||||||
func (m Model) justify(left, right string, w int, bg lipgloss.Color) string {
|
func (m Model) justify(left, right string, w int, bg color.Color) string {
|
||||||
avail := w - 2 // leading + trailing space
|
avail := w - 2 // leading + trailing space
|
||||||
lw := lipgloss.Width(left)
|
lw := lipgloss.Width(left)
|
||||||
rw := lipgloss.Width(right)
|
rw := lipgloss.Width(right)
|
||||||
@@ -798,7 +807,7 @@ func (m Model) justify(left, right string, w int, bg lipgloss.Color) string {
|
|||||||
|
|
||||||
// padTo pads (or truncates) a possibly-styled string to visible width w, filling
|
// padTo pads (or truncates) a possibly-styled string to visible width w, filling
|
||||||
// with the given background so the panel stays opaque.
|
// with the given background so the panel stays opaque.
|
||||||
func padTo(s string, w int, bg lipgloss.Color) string {
|
func padTo(s string, w int, bg color.Color) string {
|
||||||
vw := lipgloss.Width(s)
|
vw := lipgloss.Width(s)
|
||||||
if vw == w {
|
if vw == w {
|
||||||
return s
|
return s
|
||||||
@@ -825,7 +834,7 @@ func padRaw(s string, w int) string {
|
|||||||
return s + strings.Repeat(" ", w-n)
|
return s + strings.Repeat(" ", w-n)
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusColor(t Theme, status string) lipgloss.Color {
|
func statusColor(t Theme, status string) color.Color {
|
||||||
u := strings.ToUpper(status)
|
u := strings.ToUpper(status)
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(u, "FAIL"):
|
case strings.Contains(u, "FAIL"):
|
||||||
@@ -862,7 +871,7 @@ func statusGlyph(t Theme, status string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// span renders text with foreground fg over the panel background.
|
// span renders text with foreground fg over the panel background.
|
||||||
func (t Theme) span(s string, fg lipgloss.Color) string {
|
func (t Theme) span(s string, fg color.Color) string {
|
||||||
return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(s)
|
return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -8,7 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/correx/tui-go/internal/app"
|
"github.com/correx/tui-go/internal/app"
|
||||||
"github.com/correx/tui-go/internal/ws"
|
"github.com/correx/tui-go/internal/ws"
|
||||||
)
|
)
|
||||||
@@ -33,7 +33,8 @@ func main() {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
go client.Run(ctx)
|
go client.Run(ctx)
|
||||||
|
|
||||||
p := tea.NewProgram(app.NewModel(client), tea.WithAltScreen())
|
// Alt-screen is requested per-frame via the View's AltScreen field in v2.
|
||||||
|
p := tea.NewProgram(app.NewModel(client))
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "correx tui error:", err)
|
fmt.Fprintln(os.Stderr, "correx tui error:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user