diff --git a/cmd/server/main.go b/cmd/server/main.go index dec30484..c50fe933 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -8,6 +8,7 @@ import ( "errors" "flag" "fmt" + "io" "io/fs" "net/url" "os" @@ -25,6 +26,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" "github.com/router-for-me/CLIProxyAPI/v6/internal/store" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator" + "github.com/router-for-me/CLIProxyAPI/v6/internal/tui" "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" @@ -68,6 +70,7 @@ func main() { var vertexImport string var configPath string var password string + var tuiMode bool // Define command-line flags for different operation modes. flag.BoolVar(&login, "login", false, "Login Google Account") @@ -84,6 +87,7 @@ func main() { flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path") flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file") flag.StringVar(&password, "password", "", "") + flag.BoolVar(&tuiMode, "tui", false, "Start with terminal management UI") flag.CommandLine.Usage = func() { out := flag.CommandLine.Output() @@ -481,6 +485,71 @@ func main() { } // Start the main proxy service managementasset.StartAutoUpdater(context.Background(), configFilePath) - cmd.StartService(cfg, configFilePath, password) + if tuiMode { + // Install logrus hook to capture logs for TUI + hook := tui.NewLogHook(2000) + hook.SetFormatter(&logging.LogFormatter{}) + log.AddHook(hook) + // Suppress logrus stdout output (TUI owns the terminal) + log.SetOutput(io.Discard) + + // Redirect os.Stdout and os.Stderr to /dev/null so that + // stray fmt.Print* calls in the backend don't corrupt the TUI. + origStdout := os.Stdout + origStderr := os.Stderr + devNull, errNull := os.Open(os.DevNull) + if errNull == nil { + os.Stdout = devNull + os.Stderr = devNull + } + + // Generate a random local password for management API authentication. + // This is passed to the server (accepted for localhost requests) + // and used by the TUI HTTP client as the Bearer token. + localMgmtPassword := fmt.Sprintf("tui-%d-%d", os.Getpid(), time.Now().UnixNano()) + if password == "" { + password = localMgmtPassword + } + + // Ensure management routes are registered (secret-key must be set) + if cfg.RemoteManagement.SecretKey == "" { + cfg.RemoteManagement.SecretKey = "$tui-placeholder$" + } + + // Start server in background + cancel, done := cmd.StartServiceBackground(cfg, configFilePath, password) + + // Wait for server to be ready by polling management API + { + client := tui.NewClient(cfg.Port, password) + for i := 0; i < 50; i++ { + time.Sleep(100 * time.Millisecond) + if _, err := client.GetConfig(); err == nil { + break + } + } + } + + // Run TUI (blocking) โ€” use the local password for API auth + if err := tui.Run(cfg.Port, password, hook, origStdout); err != nil { + // Restore stdout/stderr before printing error + os.Stdout = origStdout + os.Stderr = origStderr + fmt.Fprintf(os.Stderr, "TUI error: %v\n", err) + } + + // Restore stdout/stderr for shutdown messages + os.Stdout = origStdout + os.Stderr = origStderr + if devNull != nil { + _ = devNull.Close() + } + + // Shutdown server + cancel() + <-done + } else { + cmd.StartService(cfg, configFilePath, password) + } } } diff --git a/go.mod b/go.mod index 38a499be..c2e4383d 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,12 @@ module github.com/router-for-me/CLIProxyAPI/v6 -go 1.24.0 +go 1.24.2 require ( github.com/andybalholm/brotli v1.0.6 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/fsnotify/fsnotify v1.9.0 github.com/gin-gonic/gin v1.10.1 github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145 @@ -31,8 +34,17 @@ require ( cloud.google.com/go/compute/metadata v0.3.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect @@ -40,6 +52,7 @@ require ( github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect @@ -56,19 +69,27 @@ require ( github.com/kevinburke/ssh_config v1.4.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // 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.19 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/xid v1.5.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect diff --git a/go.sum b/go.sum index b57b919a..3c424c5e 100644 --- a/go.sum +++ b/go.sum @@ -10,10 +10,34 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +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/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +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/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= @@ -33,6 +57,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= @@ -99,8 +125,14 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.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/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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw= @@ -112,6 +144,12 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +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/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= @@ -120,6 +158,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= @@ -159,17 +199,22 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/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= diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index e2ff23f1..3fde365b 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -808,6 +808,87 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled}) } +// PatchAuthFileFields updates editable fields (prefix, proxy_url, priority) of an auth file. +func (h *Handler) PatchAuthFileFields(c *gin.Context) { + if h.authManager == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"}) + return + } + + var req struct { + Name string `json:"name"` + Prefix *string `json:"prefix"` + ProxyURL *string `json:"proxy_url"` + Priority *int `json:"priority"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + name := strings.TrimSpace(req.Name) + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + ctx := c.Request.Context() + + // Find auth by name or ID + var targetAuth *coreauth.Auth + if auth, ok := h.authManager.GetByID(name); ok { + targetAuth = auth + } else { + auths := h.authManager.List() + for _, auth := range auths { + if auth.FileName == name { + targetAuth = auth + break + } + } + } + + if targetAuth == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"}) + return + } + + changed := false + if req.Prefix != nil { + targetAuth.Prefix = *req.Prefix + changed = true + } + if req.ProxyURL != nil { + targetAuth.ProxyURL = *req.ProxyURL + changed = true + } + if req.Priority != nil { + if targetAuth.Metadata == nil { + targetAuth.Metadata = make(map[string]any) + } + if *req.Priority == 0 { + delete(targetAuth.Metadata, "priority") + } else { + targetAuth.Metadata["priority"] = *req.Priority + } + changed = true + } + + if !changed { + c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) + return + } + + targetAuth.UpdatedAt = time.Now() + + if _, err := h.authManager.Update(ctx, targetAuth); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + func (h *Handler) disableAuth(ctx context.Context, id string) { if h == nil || h.authManager == nil { return diff --git a/internal/api/server.go b/internal/api/server.go index 4cbcbba2..a996c78c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -616,6 +616,7 @@ func (s *Server) registerManagementRoutes() { mgmt.POST("/auth-files", s.mgmt.UploadAuthFile) mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile) mgmt.PATCH("/auth-files/status", s.mgmt.PatchAuthFileStatus) + mgmt.PATCH("/auth-files/fields", s.mgmt.PatchAuthFileFields) mgmt.POST("/vertex/import", s.mgmt.ImportVertexCredential) mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken) diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 1e968126..d8c4f019 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -55,6 +55,34 @@ func StartService(cfg *config.Config, configPath string, localPassword string) { } } +// StartServiceBackground starts the proxy service in a background goroutine +// and returns a cancel function for shutdown and a done channel. +func StartServiceBackground(cfg *config.Config, configPath string, localPassword string) (cancel func(), done <-chan struct{}) { + builder := cliproxy.NewBuilder(). + WithConfig(cfg). + WithConfigPath(configPath). + WithLocalManagementPassword(localPassword) + + ctx, cancelFn := context.WithCancel(context.Background()) + doneCh := make(chan struct{}) + + service, err := builder.Build() + if err != nil { + log.Errorf("failed to build proxy service: %v", err) + close(doneCh) + return cancelFn, doneCh + } + + go func() { + defer close(doneCh) + if err := service.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { + log.Errorf("proxy service exited with error: %v", err) + } + }() + + return cancelFn, doneCh +} + // WaitForCloudDeploy waits indefinitely for shutdown signals in cloud deploy mode // when no configuration file is available. func WaitForCloudDeploy() { diff --git a/internal/tui/app.go b/internal/tui/app.go new file mode 100644 index 00000000..c6c21c2b --- /dev/null +++ b/internal/tui/app.go @@ -0,0 +1,242 @@ +package tui + +import ( + "io" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Tab identifiers +const ( + tabDashboard = iota + tabConfig + tabAuthFiles + tabAPIKeys + tabOAuth + tabUsage + tabLogs +) + +var tabNames = []string{"Dashboard", "Config", "Auth Files", "API Keys", "OAuth", "Usage", "Logs"} + +// App is the root bubbletea model that contains all tab sub-models. +type App struct { + activeTab int + tabs []string + + dashboard dashboardModel + config configTabModel + auth authTabModel + keys keysTabModel + oauth oauthTabModel + usage usageTabModel + logs logsTabModel + + client *Client + hook *LogHook + width int + height int + ready bool + + // Track which tabs have been initialized (fetched data) + initialized [7]bool +} + +// NewApp creates the root TUI application model. +func NewApp(port int, secretKey string, hook *LogHook) App { + client := NewClient(port, secretKey) + return App{ + activeTab: tabDashboard, + tabs: tabNames, + dashboard: newDashboardModel(client), + config: newConfigTabModel(client), + auth: newAuthTabModel(client), + keys: newKeysTabModel(client), + oauth: newOAuthTabModel(client), + usage: newUsageTabModel(client), + logs: newLogsTabModel(hook), + client: client, + hook: hook, + } +} + +func (a App) Init() tea.Cmd { + // Initialize dashboard and logs on start + a.initialized[tabDashboard] = true + a.initialized[tabLogs] = true + return tea.Batch( + a.dashboard.Init(), + a.logs.Init(), + ) +} + +func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + a.width = msg.Width + a.height = msg.Height + a.ready = true + contentH := a.height - 4 // tab bar + status bar + if contentH < 1 { + contentH = 1 + } + contentW := a.width + a.dashboard.SetSize(contentW, contentH) + a.config.SetSize(contentW, contentH) + a.auth.SetSize(contentW, contentH) + a.keys.SetSize(contentW, contentH) + a.oauth.SetSize(contentW, contentH) + a.usage.SetSize(contentW, contentH) + a.logs.SetSize(contentW, contentH) + return a, nil + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + return a, tea.Quit + case "q": + // Only quit if not in logs tab (where 'q' might be useful) + if a.activeTab != tabLogs { + return a, tea.Quit + } + case "tab": + prevTab := a.activeTab + a.activeTab = (a.activeTab + 1) % len(a.tabs) + return a, a.initTabIfNeeded(prevTab) + case "shift+tab": + prevTab := a.activeTab + a.activeTab = (a.activeTab - 1 + len(a.tabs)) % len(a.tabs) + return a, a.initTabIfNeeded(prevTab) + } + } + + // Route msg to active tab + var cmd tea.Cmd + switch a.activeTab { + case tabDashboard: + a.dashboard, cmd = a.dashboard.Update(msg) + case tabConfig: + a.config, cmd = a.config.Update(msg) + case tabAuthFiles: + a.auth, cmd = a.auth.Update(msg) + case tabAPIKeys: + a.keys, cmd = a.keys.Update(msg) + case tabOAuth: + a.oauth, cmd = a.oauth.Update(msg) + case tabUsage: + a.usage, cmd = a.usage.Update(msg) + case tabLogs: + a.logs, cmd = a.logs.Update(msg) + } + + // Always route logLineMsg to logs tab even if not active, + // AND capture the returned cmd to maintain the waitForLog chain. + if _, ok := msg.(logLineMsg); ok && a.activeTab != tabLogs { + var logCmd tea.Cmd + a.logs, logCmd = a.logs.Update(msg) + if logCmd != nil { + cmd = logCmd + } + } + + return a, cmd +} + +func (a *App) initTabIfNeeded(_ int) tea.Cmd { + if a.initialized[a.activeTab] { + return nil + } + a.initialized[a.activeTab] = true + switch a.activeTab { + case tabDashboard: + return a.dashboard.Init() + case tabConfig: + return a.config.Init() + case tabAuthFiles: + return a.auth.Init() + case tabAPIKeys: + return a.keys.Init() + case tabOAuth: + return a.oauth.Init() + case tabUsage: + return a.usage.Init() + case tabLogs: + return a.logs.Init() + } + return nil +} + +func (a App) View() string { + if !a.ready { + return "Initializing TUI..." + } + + var sb strings.Builder + + // Tab bar + sb.WriteString(a.renderTabBar()) + sb.WriteString("\n") + + // Content + switch a.activeTab { + case tabDashboard: + sb.WriteString(a.dashboard.View()) + case tabConfig: + sb.WriteString(a.config.View()) + case tabAuthFiles: + sb.WriteString(a.auth.View()) + case tabAPIKeys: + sb.WriteString(a.keys.View()) + case tabOAuth: + sb.WriteString(a.oauth.View()) + case tabUsage: + sb.WriteString(a.usage.View()) + case tabLogs: + sb.WriteString(a.logs.View()) + } + + // Status bar + sb.WriteString("\n") + sb.WriteString(a.renderStatusBar()) + + return sb.String() +} + +func (a App) renderTabBar() string { + var tabs []string + for i, name := range a.tabs { + if i == a.activeTab { + tabs = append(tabs, tabActiveStyle.Render(name)) + } else { + tabs = append(tabs, tabInactiveStyle.Render(name)) + } + } + tabBar := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) + return tabBarStyle.Width(a.width).Render(tabBar) +} + +func (a App) renderStatusBar() string { + left := " CLIProxyAPI Management TUI" + right := "Tab/Shift+Tab: switch โ€ข q/Ctrl+C: quit " + gap := a.width - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 0 { + gap = 0 + } + return statusBarStyle.Width(a.width).Render(left + strings.Repeat(" ", gap) + right) +} + +// Run starts the TUI application. +// output specifies where bubbletea renders. If nil, defaults to os.Stdout. +// Pass the real terminal stdout here when os.Stdout has been redirected. +func Run(port int, secretKey string, hook *LogHook, output io.Writer) error { + if output == nil { + output = os.Stdout + } + app := NewApp(port, secretKey, hook) + p := tea.NewProgram(app, tea.WithAltScreen(), tea.WithOutput(output)) + _, err := p.Run() + return err +} diff --git a/internal/tui/auth_tab.go b/internal/tui/auth_tab.go new file mode 100644 index 00000000..c6a38ae7 --- /dev/null +++ b/internal/tui/auth_tab.go @@ -0,0 +1,436 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// editableField represents an editable field on an auth file. +type editableField struct { + label string + key string // API field key: "prefix", "proxy_url", "priority" +} + +var authEditableFields = []editableField{ + {label: "Prefix", key: "prefix"}, + {label: "Proxy URL", key: "proxy_url"}, + {label: "Priority", key: "priority"}, +} + +// authTabModel displays auth credential files with interactive management. +type authTabModel struct { + client *Client + viewport viewport.Model + files []map[string]any + err error + width int + height int + ready bool + cursor int + expanded int // -1 = none expanded, >=0 = expanded index + confirm int // -1 = no confirmation, >=0 = confirm delete for index + status string + + // Editing state + editing bool // true when editing a field + editField int // index into authEditableFields + editInput textinput.Model // text input for editing + editFileName string // name of file being edited +} + +type authFilesMsg struct { + files []map[string]any + err error +} + +type authActionMsg struct { + action string // "deleted", "toggled", "updated" + err error +} + +func newAuthTabModel(client *Client) authTabModel { + ti := textinput.New() + ti.CharLimit = 256 + return authTabModel{ + client: client, + expanded: -1, + confirm: -1, + editInput: ti, + } +} + +func (m authTabModel) Init() tea.Cmd { + return m.fetchFiles +} + +func (m authTabModel) fetchFiles() tea.Msg { + files, err := m.client.GetAuthFiles() + return authFilesMsg{files: files, err: err} +} + +func (m authTabModel) Update(msg tea.Msg) (authTabModel, tea.Cmd) { + switch msg := msg.(type) { + case authFilesMsg: + if msg.err != nil { + m.err = msg.err + } else { + m.err = nil + m.files = msg.files + if m.cursor >= len(m.files) { + m.cursor = max(0, len(m.files)-1) + } + m.status = "" + } + m.viewport.SetContent(m.renderContent()) + return m, nil + + case authActionMsg: + if msg.err != nil { + m.status = errorStyle.Render("โœ— " + msg.err.Error()) + } else { + m.status = successStyle.Render("โœ“ " + msg.action) + } + m.confirm = -1 + m.viewport.SetContent(m.renderContent()) + return m, m.fetchFiles + + case tea.KeyMsg: + // ---- Editing mode ---- + if m.editing { + switch msg.String() { + case "enter": + value := m.editInput.Value() + fieldKey := authEditableFields[m.editField].key + fileName := m.editFileName + m.editing = false + m.editInput.Blur() + fields := map[string]any{} + if fieldKey == "priority" { + p, _ := strconv.Atoi(value) + fields[fieldKey] = p + } else { + fields[fieldKey] = value + } + return m, func() tea.Msg { + err := m.client.PatchAuthFileFields(fileName, fields) + if err != nil { + return authActionMsg{err: err} + } + return authActionMsg{action: fmt.Sprintf("Updated %s on %s", fieldKey, fileName)} + } + case "esc": + m.editing = false + m.editInput.Blur() + m.viewport.SetContent(m.renderContent()) + return m, nil + default: + var cmd tea.Cmd + m.editInput, cmd = m.editInput.Update(msg) + m.viewport.SetContent(m.renderContent()) + return m, cmd + } + } + + // ---- Delete confirmation mode ---- + if m.confirm >= 0 { + switch msg.String() { + case "y", "Y": + idx := m.confirm + m.confirm = -1 + if idx < len(m.files) { + name := getString(m.files[idx], "name") + return m, func() tea.Msg { + err := m.client.DeleteAuthFile(name) + if err != nil { + return authActionMsg{err: err} + } + return authActionMsg{action: fmt.Sprintf("Deleted %s", name)} + } + } + m.viewport.SetContent(m.renderContent()) + return m, nil + case "n", "N", "esc": + m.confirm = -1 + m.viewport.SetContent(m.renderContent()) + return m, nil + } + return m, nil + } + + // ---- Normal mode ---- + switch msg.String() { + case "j", "down": + if len(m.files) > 0 { + m.cursor = (m.cursor + 1) % len(m.files) + m.viewport.SetContent(m.renderContent()) + } + return m, nil + case "k", "up": + if len(m.files) > 0 { + m.cursor = (m.cursor - 1 + len(m.files)) % len(m.files) + m.viewport.SetContent(m.renderContent()) + } + return m, nil + case "enter", " ": + if m.expanded == m.cursor { + m.expanded = -1 + } else { + m.expanded = m.cursor + } + m.viewport.SetContent(m.renderContent()) + return m, nil + case "d", "D": + if m.cursor < len(m.files) { + m.confirm = m.cursor + m.viewport.SetContent(m.renderContent()) + } + return m, nil + case "e", "E": + if m.cursor < len(m.files) { + f := m.files[m.cursor] + name := getString(f, "name") + disabled := getBool(f, "disabled") + newDisabled := !disabled + return m, func() tea.Msg { + err := m.client.ToggleAuthFile(name, newDisabled) + if err != nil { + return authActionMsg{err: err} + } + action := "Enabled" + if newDisabled { + action = "Disabled" + } + return authActionMsg{action: fmt.Sprintf("%s %s", action, name)} + } + } + return m, nil + case "1": + return m, m.startEdit(0) // prefix + case "2": + return m, m.startEdit(1) // proxy_url + case "3": + return m, m.startEdit(2) // priority + case "r": + m.status = "" + return m, m.fetchFiles + default: + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +// startEdit activates inline editing for a field on the currently selected auth file. +func (m *authTabModel) startEdit(fieldIdx int) tea.Cmd { + if m.cursor >= len(m.files) { + return nil + } + f := m.files[m.cursor] + m.editFileName = getString(f, "name") + m.editField = fieldIdx + m.editing = true + + // Pre-populate with current value + key := authEditableFields[fieldIdx].key + currentVal := getAnyString(f, key) + m.editInput.SetValue(currentVal) + m.editInput.Focus() + m.editInput.Prompt = fmt.Sprintf(" %s: ", authEditableFields[fieldIdx].label) + m.viewport.SetContent(m.renderContent()) + return textinput.Blink +} + +func (m *authTabModel) SetSize(w, h int) { + m.width = w + m.height = h + m.editInput.Width = w - 20 + if !m.ready { + m.viewport = viewport.New(w, h) + m.viewport.SetContent(m.renderContent()) + m.ready = true + } else { + m.viewport.Width = w + m.viewport.Height = h + } +} + +func (m authTabModel) View() string { + if !m.ready { + return "Loading..." + } + return m.viewport.View() +} + +func (m authTabModel) renderContent() string { + var sb strings.Builder + + sb.WriteString(titleStyle.Render("๐Ÿ”‘ Auth Files")) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" [โ†‘โ†“/jk] navigate โ€ข [Enter] expand โ€ข [e] enable/disable โ€ข [d] delete โ€ข [r] refresh")) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" [1] edit prefix โ€ข [2] edit proxy_url โ€ข [3] edit priority")) + sb.WriteString("\n") + sb.WriteString(strings.Repeat("โ”€", m.width)) + sb.WriteString("\n") + + if m.err != nil { + sb.WriteString(errorStyle.Render("โš  Error: " + m.err.Error())) + sb.WriteString("\n") + return sb.String() + } + + if len(m.files) == 0 { + sb.WriteString(subtitleStyle.Render("\n No auth files found")) + sb.WriteString("\n") + return sb.String() + } + + for i, f := range m.files { + name := getString(f, "name") + channel := getString(f, "channel") + email := getString(f, "email") + disabled := getBool(f, "disabled") + + statusIcon := successStyle.Render("โ—") + statusText := "active" + if disabled { + statusIcon = lipgloss.NewStyle().Foreground(colorMuted).Render("โ—‹") + statusText = "disabled" + } + + cursor := " " + rowStyle := lipgloss.NewStyle() + if i == m.cursor { + cursor = "โ–ธ " + rowStyle = lipgloss.NewStyle().Bold(true) + } + + displayName := name + if len(displayName) > 24 { + displayName = displayName[:21] + "..." + } + displayEmail := email + if len(displayEmail) > 28 { + displayEmail = displayEmail[:25] + "..." + } + + row := fmt.Sprintf("%s%s %-24s %-12s %-28s %s", + cursor, statusIcon, displayName, channel, displayEmail, statusText) + sb.WriteString(rowStyle.Render(row)) + sb.WriteString("\n") + + // Delete confirmation + if m.confirm == i { + sb.WriteString(warningStyle.Render(fmt.Sprintf(" โš  Delete %s? [y/n] ", name))) + sb.WriteString("\n") + } + + // Inline edit input + if m.editing && i == m.cursor { + sb.WriteString(m.editInput.View()) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" Enter: save โ€ข Esc: cancel")) + sb.WriteString("\n") + } + + // Expanded detail view + if m.expanded == i { + sb.WriteString(m.renderDetail(f)) + } + } + + if m.status != "" { + sb.WriteString("\n") + sb.WriteString(m.status) + sb.WriteString("\n") + } + + return sb.String() +} + +func (m authTabModel) renderDetail(f map[string]any) string { + var sb strings.Builder + + labelStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("111")). + Bold(true) + valueStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + editableMarker := lipgloss.NewStyle(). + Foreground(lipgloss.Color("214")). + Render(" โœŽ") + + sb.WriteString(" โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n") + + fields := []struct { + label string + key string + editable bool + }{ + {"Name", "name", false}, + {"Channel", "channel", false}, + {"Email", "email", false}, + {"Status", "status", false}, + {"Status Msg", "status_message", false}, + {"File Name", "file_name", false}, + {"Auth Type", "auth_type", false}, + {"Prefix", "prefix", true}, + {"Proxy URL", "proxy_url", true}, + {"Priority", "priority", true}, + {"Project ID", "project_id", false}, + {"Disabled", "disabled", false}, + {"Created", "created_at", false}, + {"Updated", "updated_at", false}, + } + + for _, field := range fields { + val := getAnyString(f, field.key) + if val == "" || val == "" { + if field.editable { + val = "(not set)" + } else { + continue + } + } + editMark := "" + if field.editable { + editMark = editableMarker + } + line := fmt.Sprintf(" โ”‚ %s %s%s", + labelStyle.Render(fmt.Sprintf("%-12s:", field.label)), + valueStyle.Render(val), + editMark) + sb.WriteString(line) + sb.WriteString("\n") + } + + sb.WriteString(" โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n") + return sb.String() +} + +// getAnyString converts any value to its string representation. +func getAnyString(m map[string]any, key string) string { + v, ok := m[key] + if !ok || v == nil { + return "" + } + return fmt.Sprintf("%v", v) +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/tui/browser.go b/internal/tui/browser.go new file mode 100644 index 00000000..5532a5a2 --- /dev/null +++ b/internal/tui/browser.go @@ -0,0 +1,20 @@ +package tui + +import ( + "os/exec" + "runtime" +) + +// openBrowser opens the specified URL in the user's default browser. +func openBrowser(url string) error { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", url).Start() + case "linux": + return exec.Command("xdg-open", url).Start() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + default: + return exec.Command("xdg-open", url).Start() + } +} diff --git a/internal/tui/client.go b/internal/tui/client.go new file mode 100644 index 00000000..b2e15e68 --- /dev/null +++ b/internal/tui/client.go @@ -0,0 +1,314 @@ +package tui + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Client wraps HTTP calls to the management API. +type Client struct { + baseURL string + secretKey string + http *http.Client +} + +// NewClient creates a new management API client. +func NewClient(port int, secretKey string) *Client { + return &Client{ + baseURL: fmt.Sprintf("http://127.0.0.1:%d", port), + secretKey: secretKey, + http: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +func (c *Client) doRequest(method, path string, body io.Reader) ([]byte, int, error) { + url := c.baseURL + path + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, 0, err + } + if c.secretKey != "" { + req.Header.Set("Authorization", "Bearer "+c.secretKey) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + return data, resp.StatusCode, nil +} + +func (c *Client) get(path string) ([]byte, error) { + data, code, err := c.doRequest("GET", path, nil) + if err != nil { + return nil, err + } + if code >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", code, strings.TrimSpace(string(data))) + } + return data, nil +} + +func (c *Client) put(path string, body io.Reader) ([]byte, error) { + data, code, err := c.doRequest("PUT", path, body) + if err != nil { + return nil, err + } + if code >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", code, strings.TrimSpace(string(data))) + } + return data, nil +} + +func (c *Client) patch(path string, body io.Reader) ([]byte, error) { + data, code, err := c.doRequest("PATCH", path, body) + if err != nil { + return nil, err + } + if code >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", code, strings.TrimSpace(string(data))) + } + return data, nil +} + +// getJSON fetches a path and unmarshals JSON into a generic map. +func (c *Client) getJSON(path string) (map[string]any, error) { + data, err := c.get(path) + if err != nil { + return nil, err + } + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return result, nil +} + +// postJSON sends a JSON body via POST and checks for errors. +func (c *Client) postJSON(path string, body any) error { + jsonBody, err := json.Marshal(body) + if err != nil { + return err + } + _, code, err := c.doRequest("POST", path, strings.NewReader(string(jsonBody))) + if err != nil { + return err + } + if code >= 400 { + return fmt.Errorf("HTTP %d", code) + } + return nil +} + +// GetConfig fetches the parsed config. +func (c *Client) GetConfig() (map[string]any, error) { + return c.getJSON("/v0/management/config") +} + +// GetConfigYAML fetches the raw config.yaml content. +func (c *Client) GetConfigYAML() (string, error) { + data, err := c.get("/v0/management/config.yaml") + if err != nil { + return "", err + } + return string(data), nil +} + +// PutConfigYAML uploads new config.yaml content. +func (c *Client) PutConfigYAML(yamlContent string) error { + _, err := c.put("/v0/management/config.yaml", strings.NewReader(yamlContent)) + return err +} + +// GetUsage fetches usage statistics. +func (c *Client) GetUsage() (map[string]any, error) { + return c.getJSON("/v0/management/usage") +} + +// GetAuthFiles lists auth credential files. +// API returns {"files": [...]}. +func (c *Client) GetAuthFiles() ([]map[string]any, error) { + wrapper, err := c.getJSON("/v0/management/auth-files") + if err != nil { + return nil, err + } + return extractList(wrapper, "files") +} + +// DeleteAuthFile deletes a single auth file by name. +func (c *Client) DeleteAuthFile(name string) error { + _, code, err := c.doRequest("DELETE", "/v0/management/auth-files?name="+name, nil) + if err != nil { + return err + } + if code >= 400 { + return fmt.Errorf("delete failed (HTTP %d)", code) + } + return nil +} + +// ToggleAuthFile enables or disables an auth file. +func (c *Client) ToggleAuthFile(name string, disabled bool) error { + body, _ := json.Marshal(map[string]any{"name": name, "disabled": disabled}) + _, err := c.patch("/v0/management/auth-files/status", strings.NewReader(string(body))) + return err +} + +// PatchAuthFileFields updates editable fields on an auth file. +func (c *Client) PatchAuthFileFields(name string, fields map[string]any) error { + fields["name"] = name + body, _ := json.Marshal(fields) + _, err := c.patch("/v0/management/auth-files/fields", strings.NewReader(string(body))) + return err +} + +// GetLogs fetches log lines from the server. +func (c *Client) GetLogs(cutoff int64, limit int) (map[string]any, error) { + path := fmt.Sprintf("/v0/management/logs?limit=%d", limit) + if cutoff > 0 { + path += fmt.Sprintf("&cutoff=%d", cutoff) + } + return c.getJSON(path) +} + +// GetAPIKeys fetches the list of API keys. +// API returns {"api-keys": [...]}. +func (c *Client) GetAPIKeys() ([]string, error) { + wrapper, err := c.getJSON("/v0/management/api-keys") + if err != nil { + return nil, err + } + arr, ok := wrapper["api-keys"] + if !ok { + return nil, nil + } + raw, err := json.Marshal(arr) + if err != nil { + return nil, err + } + var result []string + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return result, nil +} + +// GetGeminiKeys fetches Gemini API keys. +// API returns {"gemini-api-key": [...]}. +func (c *Client) GetGeminiKeys() ([]map[string]any, error) { + return c.getWrappedKeyList("/v0/management/gemini-api-key", "gemini-api-key") +} + +// GetClaudeKeys fetches Claude API keys. +func (c *Client) GetClaudeKeys() ([]map[string]any, error) { + return c.getWrappedKeyList("/v0/management/claude-api-key", "claude-api-key") +} + +// GetCodexKeys fetches Codex API keys. +func (c *Client) GetCodexKeys() ([]map[string]any, error) { + return c.getWrappedKeyList("/v0/management/codex-api-key", "codex-api-key") +} + +// GetVertexKeys fetches Vertex API keys. +func (c *Client) GetVertexKeys() ([]map[string]any, error) { + return c.getWrappedKeyList("/v0/management/vertex-api-key", "vertex-api-key") +} + +// GetOpenAICompat fetches OpenAI compatibility entries. +func (c *Client) GetOpenAICompat() ([]map[string]any, error) { + return c.getWrappedKeyList("/v0/management/openai-compatibility", "openai-compatibility") +} + +// getWrappedKeyList fetches a wrapped list from the API. +func (c *Client) getWrappedKeyList(path, key string) ([]map[string]any, error) { + wrapper, err := c.getJSON(path) + if err != nil { + return nil, err + } + return extractList(wrapper, key) +} + +// extractList pulls an array of maps from a wrapper object by key. +func extractList(wrapper map[string]any, key string) ([]map[string]any, error) { + arr, ok := wrapper[key] + if !ok || arr == nil { + return nil, nil + } + raw, err := json.Marshal(arr) + if err != nil { + return nil, err + } + var result []map[string]any + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return result, nil +} + +// GetDebug fetches the current debug setting. +func (c *Client) GetDebug() (bool, error) { + wrapper, err := c.getJSON("/v0/management/debug") + if err != nil { + return false, err + } + if v, ok := wrapper["debug"]; ok { + if b, ok := v.(bool); ok { + return b, nil + } + } + return false, nil +} + +// GetAuthStatus polls the OAuth session status. +// Returns status ("wait", "ok", "error") and optional error message. +func (c *Client) GetAuthStatus(state string) (string, string, error) { + wrapper, err := c.getJSON("/v0/management/get-auth-status?state=" + state) + if err != nil { + return "", "", err + } + status := getString(wrapper, "status") + errMsg := getString(wrapper, "error") + return status, errMsg, nil +} + +// ----- Config field update methods ----- + +// PutBoolField updates a boolean config field. +func (c *Client) PutBoolField(path string, value bool) error { + body, _ := json.Marshal(map[string]any{"value": value}) + _, err := c.put("/v0/management/"+path, strings.NewReader(string(body))) + return err +} + +// PutIntField updates an integer config field. +func (c *Client) PutIntField(path string, value int) error { + body, _ := json.Marshal(map[string]any{"value": value}) + _, err := c.put("/v0/management/"+path, strings.NewReader(string(body))) + return err +} + +// PutStringField updates a string config field. +func (c *Client) PutStringField(path string, value string) error { + body, _ := json.Marshal(map[string]any{"value": value}) + _, err := c.put("/v0/management/"+path, strings.NewReader(string(body))) + return err +} + +// DeleteField sends a DELETE request for a config field. +func (c *Client) DeleteField(path string) error { + _, _, err := c.doRequest("DELETE", "/v0/management/"+path, nil) + return err +} diff --git a/internal/tui/config_tab.go b/internal/tui/config_tab.go new file mode 100644 index 00000000..39f3ce68 --- /dev/null +++ b/internal/tui/config_tab.go @@ -0,0 +1,384 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// configField represents a single editable config field. +type configField struct { + label string + apiPath string // management API path (e.g. "debug", "proxy-url") + kind string // "bool", "int", "string", "readonly" + value string // current display value + rawValue any // raw value from API +} + +// configTabModel displays parsed config with interactive editing. +type configTabModel struct { + client *Client + viewport viewport.Model + fields []configField + cursor int + editing bool + textInput textinput.Model + err error + message string // status message (success/error) + width int + height int + ready bool +} + +type configDataMsg struct { + config map[string]any + err error +} + +type configUpdateMsg struct { + err error +} + +func newConfigTabModel(client *Client) configTabModel { + ti := textinput.New() + ti.CharLimit = 256 + return configTabModel{ + client: client, + textInput: ti, + } +} + +func (m configTabModel) Init() tea.Cmd { + return m.fetchConfig +} + +func (m configTabModel) fetchConfig() tea.Msg { + cfg, err := m.client.GetConfig() + return configDataMsg{config: cfg, err: err} +} + +func (m configTabModel) Update(msg tea.Msg) (configTabModel, tea.Cmd) { + switch msg := msg.(type) { + case configDataMsg: + if msg.err != nil { + m.err = msg.err + m.fields = nil + } else { + m.err = nil + m.fields = m.parseConfig(msg.config) + } + m.viewport.SetContent(m.renderContent()) + return m, nil + + case configUpdateMsg: + if msg.err != nil { + m.message = errorStyle.Render("โœ— " + msg.err.Error()) + } else { + m.message = successStyle.Render("โœ“ Updated successfully") + } + m.viewport.SetContent(m.renderContent()) + // Refresh config from server + return m, m.fetchConfig + + case tea.KeyMsg: + if m.editing { + return m.handleEditingKey(msg) + } + return m.handleNormalKey(msg) + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m configTabModel) handleNormalKey(msg tea.KeyMsg) (configTabModel, tea.Cmd) { + switch msg.String() { + case "r": + m.message = "" + return m, m.fetchConfig + case "up", "k": + if m.cursor > 0 { + m.cursor-- + m.viewport.SetContent(m.renderContent()) + // Ensure cursor is visible + m.ensureCursorVisible() + } + return m, nil + case "down", "j": + if m.cursor < len(m.fields)-1 { + m.cursor++ + m.viewport.SetContent(m.renderContent()) + m.ensureCursorVisible() + } + return m, nil + case "enter", " ": + if m.cursor >= 0 && m.cursor < len(m.fields) { + f := m.fields[m.cursor] + if f.kind == "readonly" { + return m, nil + } + if f.kind == "bool" { + // Toggle directly + return m, m.toggleBool(m.cursor) + } + // Start editing for int/string + m.editing = true + m.textInput.SetValue(f.value) + m.textInput.Focus() + m.viewport.SetContent(m.renderContent()) + return m, textinput.Blink + } + return m, nil + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m configTabModel) handleEditingKey(msg tea.KeyMsg) (configTabModel, tea.Cmd) { + switch msg.String() { + case "enter": + m.editing = false + m.textInput.Blur() + return m, m.submitEdit(m.cursor, m.textInput.Value()) + case "esc": + m.editing = false + m.textInput.Blur() + m.viewport.SetContent(m.renderContent()) + return m, nil + default: + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + m.viewport.SetContent(m.renderContent()) + return m, cmd + } +} + +func (m configTabModel) toggleBool(idx int) tea.Cmd { + return func() tea.Msg { + f := m.fields[idx] + current := f.value == "true" + err := m.client.PutBoolField(f.apiPath, !current) + return configUpdateMsg{err: err} + } +} + +func (m configTabModel) submitEdit(idx int, newValue string) tea.Cmd { + return func() tea.Msg { + f := m.fields[idx] + var err error + switch f.kind { + case "int": + v, parseErr := strconv.Atoi(newValue) + if parseErr != nil { + return configUpdateMsg{err: fmt.Errorf("invalid integer: %s", newValue)} + } + err = m.client.PutIntField(f.apiPath, v) + case "string": + err = m.client.PutStringField(f.apiPath, newValue) + } + return configUpdateMsg{err: err} + } +} + +func (m *configTabModel) SetSize(w, h int) { + m.width = w + m.height = h + if !m.ready { + m.viewport = viewport.New(w, h) + m.viewport.SetContent(m.renderContent()) + m.ready = true + } else { + m.viewport.Width = w + m.viewport.Height = h + } +} + +func (m *configTabModel) ensureCursorVisible() { + // Each field takes ~1 line, header takes ~4 lines + targetLine := m.cursor + 5 + if targetLine < m.viewport.YOffset { + m.viewport.SetYOffset(targetLine) + } + if targetLine >= m.viewport.YOffset+m.viewport.Height { + m.viewport.SetYOffset(targetLine - m.viewport.Height + 1) + } +} + +func (m configTabModel) View() string { + if !m.ready { + return "Loading..." + } + return m.viewport.View() +} + +func (m configTabModel) renderContent() string { + var sb strings.Builder + + sb.WriteString(titleStyle.Render("โš™ Configuration")) + sb.WriteString("\n") + + if m.message != "" { + sb.WriteString(" " + m.message) + sb.WriteString("\n") + } + + sb.WriteString(helpStyle.Render(" [โ†‘โ†“/jk] navigate โ€ข [Enter/Space] edit โ€ข [r] refresh")) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" Bool fields: Enter to toggle โ€ข String/Int: Enter to type, Enter to confirm, Esc to cancel")) + sb.WriteString("\n\n") + + if m.err != nil { + sb.WriteString(errorStyle.Render(" โš  Error: " + m.err.Error())) + return sb.String() + } + + if len(m.fields) == 0 { + sb.WriteString(subtitleStyle.Render(" No configuration loaded")) + return sb.String() + } + + currentSection := "" + for i, f := range m.fields { + // Section headers + section := fieldSection(f.apiPath) + if section != currentSection { + currentSection = section + sb.WriteString("\n") + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(" โ”€โ”€ " + section + " ")) + sb.WriteString("\n") + } + + isSelected := i == m.cursor + prefix := " " + if isSelected { + prefix = "โ–ธ " + } + + labelStr := lipgloss.NewStyle(). + Foreground(colorInfo). + Bold(isSelected). + Width(32). + Render(f.label) + + var valueStr string + if m.editing && isSelected { + valueStr = m.textInput.View() + } else { + switch f.kind { + case "bool": + if f.value == "true" { + valueStr = successStyle.Render("โ— ON") + } else { + valueStr = lipgloss.NewStyle().Foreground(colorMuted).Render("โ—‹ OFF") + } + case "readonly": + valueStr = lipgloss.NewStyle().Foreground(colorSubtext).Render(f.value) + default: + valueStr = valueStyle.Render(f.value) + } + } + + line := prefix + labelStr + " " + valueStr + if isSelected && !m.editing { + line = lipgloss.NewStyle().Background(colorSurface).Render(line) + } + sb.WriteString(line + "\n") + } + + return sb.String() +} + +func (m configTabModel) parseConfig(cfg map[string]any) []configField { + var fields []configField + + // Server settings + fields = append(fields, configField{"Port", "port", "readonly", fmt.Sprintf("%.0f", getFloat(cfg, "port")), nil}) + fields = append(fields, configField{"Host", "host", "readonly", getString(cfg, "host"), nil}) + fields = append(fields, configField{"Debug", "debug", "bool", fmt.Sprintf("%v", getBool(cfg, "debug")), nil}) + fields = append(fields, configField{"Proxy URL", "proxy-url", "string", getString(cfg, "proxy-url"), nil}) + fields = append(fields, configField{"Request Retry", "request-retry", "int", fmt.Sprintf("%.0f", getFloat(cfg, "request-retry")), nil}) + fields = append(fields, configField{"Max Retry Interval (s)", "max-retry-interval", "int", fmt.Sprintf("%.0f", getFloat(cfg, "max-retry-interval")), nil}) + fields = append(fields, configField{"Force Model Prefix", "force-model-prefix", "string", getString(cfg, "force-model-prefix"), nil}) + + // Logging + fields = append(fields, configField{"Logging to File", "logging-to-file", "bool", fmt.Sprintf("%v", getBool(cfg, "logging-to-file")), nil}) + fields = append(fields, configField{"Logs Max Total Size (MB)", "logs-max-total-size-mb", "int", fmt.Sprintf("%.0f", getFloat(cfg, "logs-max-total-size-mb")), nil}) + fields = append(fields, configField{"Error Logs Max Files", "error-logs-max-files", "int", fmt.Sprintf("%.0f", getFloat(cfg, "error-logs-max-files")), nil}) + fields = append(fields, configField{"Usage Stats Enabled", "usage-statistics-enabled", "bool", fmt.Sprintf("%v", getBool(cfg, "usage-statistics-enabled")), nil}) + fields = append(fields, configField{"Request Log", "request-log", "bool", fmt.Sprintf("%v", getBool(cfg, "request-log")), nil}) + + // Quota exceeded + fields = append(fields, configField{"Switch Project on Quota", "quota-exceeded/switch-project", "bool", fmt.Sprintf("%v", getBoolNested(cfg, "quota-exceeded", "switch-project")), nil}) + fields = append(fields, configField{"Switch Preview Model", "quota-exceeded/switch-preview-model", "bool", fmt.Sprintf("%v", getBoolNested(cfg, "quota-exceeded", "switch-preview-model")), nil}) + + // Routing + if routing, ok := cfg["routing"].(map[string]any); ok { + fields = append(fields, configField{"Routing Strategy", "routing/strategy", "string", getString(routing, "strategy"), nil}) + } else { + fields = append(fields, configField{"Routing Strategy", "routing/strategy", "string", "", nil}) + } + + // WebSocket auth + fields = append(fields, configField{"WebSocket Auth", "ws-auth", "bool", fmt.Sprintf("%v", getBool(cfg, "ws-auth")), nil}) + + // AMP settings + if amp, ok := cfg["ampcode"].(map[string]any); ok { + fields = append(fields, configField{"AMP Upstream URL", "ampcode/upstream-url", "string", getString(amp, "upstream-url"), nil}) + fields = append(fields, configField{"AMP Upstream API Key", "ampcode/upstream-api-key", "string", maskIfNotEmpty(getString(amp, "upstream-api-key")), nil}) + fields = append(fields, configField{"AMP Restrict Mgmt Localhost", "ampcode/restrict-management-to-localhost", "bool", fmt.Sprintf("%v", getBool(amp, "restrict-management-to-localhost")), nil}) + } + + return fields +} + +func fieldSection(apiPath string) string { + if strings.HasPrefix(apiPath, "ampcode/") { + return "AMP Code" + } + if strings.HasPrefix(apiPath, "quota-exceeded/") { + return "Quota Exceeded Handling" + } + if strings.HasPrefix(apiPath, "routing/") { + return "Routing" + } + switch apiPath { + case "port", "host", "debug", "proxy-url", "request-retry", "max-retry-interval", "force-model-prefix": + return "Server" + case "logging-to-file", "logs-max-total-size-mb", "error-logs-max-files", "usage-statistics-enabled", "request-log": + return "Logging & Stats" + case "ws-auth": + return "WebSocket" + default: + return "Other" + } +} + +func getBoolNested(m map[string]any, keys ...string) bool { + current := m + for i, key := range keys { + if i == len(keys)-1 { + return getBool(current, key) + } + if nested, ok := current[key].(map[string]any); ok { + current = nested + } else { + return false + } + } + return false +} + +func maskIfNotEmpty(s string) string { + if s == "" { + return "(not set)" + } + return maskKey(s) +} diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go new file mode 100644 index 00000000..02033830 --- /dev/null +++ b/internal/tui/dashboard.go @@ -0,0 +1,345 @@ +package tui + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// dashboardModel displays server info, stats cards, and config overview. +type dashboardModel struct { + client *Client + viewport viewport.Model + content string + err error + width int + height int + ready bool +} + +type dashboardDataMsg struct { + config map[string]any + usage map[string]any + authFiles []map[string]any + apiKeys []string + err error +} + +func newDashboardModel(client *Client) dashboardModel { + return dashboardModel{ + client: client, + } +} + +func (m dashboardModel) Init() tea.Cmd { + return m.fetchData +} + +func (m dashboardModel) fetchData() tea.Msg { + cfg, cfgErr := m.client.GetConfig() + usage, usageErr := m.client.GetUsage() + authFiles, authErr := m.client.GetAuthFiles() + apiKeys, keysErr := m.client.GetAPIKeys() + + var err error + for _, e := range []error{cfgErr, usageErr, authErr, keysErr} { + if e != nil { + err = e + break + } + } + return dashboardDataMsg{config: cfg, usage: usage, authFiles: authFiles, apiKeys: apiKeys, err: err} +} + +func (m dashboardModel) Update(msg tea.Msg) (dashboardModel, tea.Cmd) { + switch msg := msg.(type) { + case dashboardDataMsg: + if msg.err != nil { + m.err = msg.err + m.content = errorStyle.Render("โš  Error: " + msg.err.Error()) + } else { + m.err = nil + m.content = m.renderDashboard(msg.config, msg.usage, msg.authFiles, msg.apiKeys) + } + m.viewport.SetContent(m.content) + return m, nil + + case tea.KeyMsg: + if msg.String() == "r" { + return m, m.fetchData + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m *dashboardModel) SetSize(w, h int) { + m.width = w + m.height = h + if !m.ready { + m.viewport = viewport.New(w, h) + m.viewport.SetContent(m.content) + m.ready = true + } else { + m.viewport.Width = w + m.viewport.Height = h + } +} + +func (m dashboardModel) View() string { + if !m.ready { + return "Loading..." + } + return m.viewport.View() +} + +func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []map[string]any, apiKeys []string) string { + var sb strings.Builder + + sb.WriteString(titleStyle.Render("๐Ÿ“Š Dashboard")) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" [r] refresh โ€ข [โ†‘โ†“] scroll")) + sb.WriteString("\n\n") + + // โ”โ”โ” Connection Status โ”โ”โ” + port := 0.0 + if cfg != nil { + port = getFloat(cfg, "port") + } + connStyle := lipgloss.NewStyle().Bold(true).Foreground(colorSuccess) + sb.WriteString(connStyle.Render("โ— ๅทฒ่ฟžๆŽฅ")) + sb.WriteString(fmt.Sprintf(" http://127.0.0.1:%.0f", port)) + sb.WriteString("\n\n") + + // โ”โ”โ” Stats Cards โ”โ”โ” + cardWidth := 25 + if m.width > 0 { + cardWidth = (m.width - 6) / 4 + if cardWidth < 18 { + cardWidth = 18 + } + } + + cardStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")). + Padding(0, 1). + Width(cardWidth). + Height(2) + + // Card 1: API Keys + keyCount := len(apiKeys) + card1 := cardStyle.Render(fmt.Sprintf( + "%s\n%s", + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")).Render(fmt.Sprintf("๐Ÿ”‘ %d", keyCount)), + lipgloss.NewStyle().Foreground(colorMuted).Render("็ฎก็†ๅฏ†้’ฅ"), + )) + + // Card 2: Auth Files + authCount := len(authFiles) + activeAuth := 0 + for _, f := range authFiles { + if !getBool(f, "disabled") { + activeAuth++ + } + } + card2 := cardStyle.Render(fmt.Sprintf( + "%s\n%s", + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("76")).Render(fmt.Sprintf("๐Ÿ“„ %d", authCount)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("่ฎค่ฏๆ–‡ไปถ (%d active)", activeAuth)), + )) + + // Card 3: Total Requests + totalReqs := int64(0) + successReqs := int64(0) + failedReqs := int64(0) + totalTokens := int64(0) + if usage != nil { + if usageMap, ok := usage["usage"].(map[string]any); ok { + totalReqs = int64(getFloat(usageMap, "total_requests")) + successReqs = int64(getFloat(usageMap, "success_count")) + failedReqs = int64(getFloat(usageMap, "failure_count")) + totalTokens = int64(getFloat(usageMap, "total_tokens")) + } + } + card3 := cardStyle.Render(fmt.Sprintf( + "%s\n%s", + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(fmt.Sprintf("๐Ÿ“ˆ %d", totalReqs)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("่ฏทๆฑ‚ (โœ“%d โœ—%d)", successReqs, failedReqs)), + )) + + // Card 4: Total Tokens + tokenStr := formatLargeNumber(totalTokens) + card4 := cardStyle.Render(fmt.Sprintf( + "%s\n%s", + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("๐Ÿ”ค %s", tokenStr)), + lipgloss.NewStyle().Foreground(colorMuted).Render("ๆ€ป Tokens"), + )) + + sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2, " ", card3, " ", card4)) + sb.WriteString("\n\n") + + // โ”โ”โ” Current Config โ”โ”โ” + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("ๅฝ“ๅ‰้…็ฝฎ")) + sb.WriteString("\n") + sb.WriteString(strings.Repeat("โ”€", minInt(m.width, 60))) + sb.WriteString("\n") + + if cfg != nil { + debug := getBool(cfg, "debug") + retry := getFloat(cfg, "request-retry") + proxyURL := getString(cfg, "proxy-url") + loggingToFile := getBool(cfg, "logging-to-file") + usageEnabled := true + if v, ok := cfg["usage-statistics-enabled"]; ok { + if b, ok2 := v.(bool); ok2 { + usageEnabled = b + } + } + + configItems := []struct { + label string + value string + }{ + {"ๅฏ็”จ่ฐƒ่ฏ•ๆจกๅผ", boolEmoji(debug)}, + {"ๅฏ็”จไฝฟ็”จ็ปŸ่ฎก", boolEmoji(usageEnabled)}, + {"ๅฏ็”จๆ—ฅๅฟ—่ฎฐๅฝ•ๅˆฐๆ–‡ไปถ", boolEmoji(loggingToFile)}, + {"้‡่ฏ•ๆฌกๆ•ฐ", fmt.Sprintf("%.0f", retry)}, + } + if proxyURL != "" { + configItems = append(configItems, struct { + label string + value string + }{"ไปฃ็† URL", proxyURL}) + } + + // Render config items as a compact row + for _, item := range configItems { + sb.WriteString(fmt.Sprintf(" %s %s\n", + labelStyle.Render(item.label+":"), + valueStyle.Render(item.value))) + } + + // Routing strategy + strategy := "round-robin" + if routing, ok := cfg["routing"].(map[string]any); ok { + if s := getString(routing, "strategy"); s != "" { + strategy = s + } + } + sb.WriteString(fmt.Sprintf(" %s %s\n", + labelStyle.Render("่ทฏ็”ฑ็ญ–็•ฅ:"), + valueStyle.Render(strategy))) + } + + sb.WriteString("\n") + + // โ”โ”โ” Per-Model Usage โ”โ”โ” + if usage != nil { + if usageMap, ok := usage["usage"].(map[string]any); ok { + if apis, ok := usageMap["apis"].(map[string]any); ok && len(apis) > 0 { + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("ๆจกๅž‹็ปŸ่ฎก")) + sb.WriteString("\n") + sb.WriteString(strings.Repeat("โ”€", minInt(m.width, 60))) + sb.WriteString("\n") + + header := fmt.Sprintf(" %-40s %10s %12s", "Model", "Requests", "Tokens") + sb.WriteString(tableHeaderStyle.Render(header)) + sb.WriteString("\n") + + for _, apiSnap := range apis { + if apiMap, ok := apiSnap.(map[string]any); ok { + if models, ok := apiMap["models"].(map[string]any); ok { + for model, v := range models { + if stats, ok := v.(map[string]any); ok { + reqs := int64(getFloat(stats, "total_requests")) + toks := int64(getFloat(stats, "total_tokens")) + row := fmt.Sprintf(" %-40s %10d %12s", truncate(model, 40), reqs, formatLargeNumber(toks)) + sb.WriteString(tableCellStyle.Render(row)) + sb.WriteString("\n") + } + } + } + } + } + } + } + } + + return sb.String() +} + +func formatKV(key, value string) string { + return fmt.Sprintf(" %s %s\n", labelStyle.Render(key+":"), valueStyle.Render(value)) +} + +func getString(m map[string]any, key string) string { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +func getFloat(m map[string]any, key string) float64 { + if v, ok := m[key]; ok { + switch n := v.(type) { + case float64: + return n + case json.Number: + f, _ := n.Float64() + return f + } + } + return 0 +} + +func getBool(m map[string]any, key string) bool { + if v, ok := m[key]; ok { + if b, ok := v.(bool); ok { + return b + } + } + return false +} + +func boolEmoji(b bool) string { + if b { + return "ๆ˜ฏ โœ“" + } + return "ๅฆ" +} + +func formatLargeNumber(n int64) string { + if n >= 1_000_000 { + return fmt.Sprintf("%.1fM", float64(n)/1_000_000) + } + if n >= 1_000 { + return fmt.Sprintf("%.1fK", float64(n)/1_000) + } + return fmt.Sprintf("%d", n) +} + +func truncate(s string, maxLen int) string { + if len(s) > maxLen { + return s[:maxLen-3] + "..." + } + return s +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/tui/keys_tab.go b/internal/tui/keys_tab.go new file mode 100644 index 00000000..20e9e0f0 --- /dev/null +++ b/internal/tui/keys_tab.go @@ -0,0 +1,190 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" +) + +// keysTabModel displays API keys from all providers. +type keysTabModel struct { + client *Client + viewport viewport.Model + content string + err error + width int + height int + ready bool +} + +type keysDataMsg struct { + apiKeys []string + gemini []map[string]any + claude []map[string]any + codex []map[string]any + vertex []map[string]any + openai []map[string]any + err error +} + +func newKeysTabModel(client *Client) keysTabModel { + return keysTabModel{ + client: client, + } +} + +func (m keysTabModel) Init() tea.Cmd { + return m.fetchKeys +} + +func (m keysTabModel) fetchKeys() tea.Msg { + result := keysDataMsg{} + + apiKeys, err := m.client.GetAPIKeys() + if err != nil { + result.err = err + return result + } + result.apiKeys = apiKeys + + // Fetch all key types, ignoring individual errors (they may not be configured) + result.gemini, _ = m.client.GetGeminiKeys() + result.claude, _ = m.client.GetClaudeKeys() + result.codex, _ = m.client.GetCodexKeys() + result.vertex, _ = m.client.GetVertexKeys() + result.openai, _ = m.client.GetOpenAICompat() + + return result +} + +func (m keysTabModel) Update(msg tea.Msg) (keysTabModel, tea.Cmd) { + switch msg := msg.(type) { + case keysDataMsg: + if msg.err != nil { + m.err = msg.err + m.content = errorStyle.Render("โš  Error: " + msg.err.Error()) + } else { + m.err = nil + m.content = m.renderKeys(msg) + } + m.viewport.SetContent(m.content) + return m, nil + + case tea.KeyMsg: + if msg.String() == "r" { + return m, m.fetchKeys + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m *keysTabModel) SetSize(w, h int) { + m.width = w + m.height = h + if !m.ready { + m.viewport = viewport.New(w, h) + m.viewport.SetContent(m.content) + m.ready = true + } else { + m.viewport.Width = w + m.viewport.Height = h + } +} + +func (m keysTabModel) View() string { + if !m.ready { + return "Loading..." + } + return m.viewport.View() +} + +func (m keysTabModel) renderKeys(data keysDataMsg) string { + var sb strings.Builder + + sb.WriteString(titleStyle.Render("๐Ÿ” API Keys")) + sb.WriteString("\n\n") + + // API Keys (access keys) + renderSection(&sb, "Access API Keys", len(data.apiKeys)) + for i, key := range data.apiKeys { + sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, maskKey(key))) + } + sb.WriteString("\n") + + // Gemini Keys + renderProviderKeys(&sb, "Gemini API Keys", data.gemini) + + // Claude Keys + renderProviderKeys(&sb, "Claude API Keys", data.claude) + + // Codex Keys + renderProviderKeys(&sb, "Codex API Keys", data.codex) + + // Vertex Keys + renderProviderKeys(&sb, "Vertex API Keys", data.vertex) + + // OpenAI Compatibility + if len(data.openai) > 0 { + renderSection(&sb, "OpenAI Compatibility", len(data.openai)) + for i, entry := range data.openai { + name := getString(entry, "name") + baseURL := getString(entry, "base-url") + prefix := getString(entry, "prefix") + info := name + if prefix != "" { + info += " (prefix: " + prefix + ")" + } + if baseURL != "" { + info += " โ†’ " + baseURL + } + sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, info)) + } + sb.WriteString("\n") + } + + sb.WriteString(helpStyle.Render("Press [r] to refresh โ€ข [โ†‘โ†“] to scroll")) + + return sb.String() +} + +func renderSection(sb *strings.Builder, title string, count int) { + header := fmt.Sprintf("%s (%d)", title, count) + sb.WriteString(tableHeaderStyle.Render(" " + header)) + sb.WriteString("\n") +} + +func renderProviderKeys(sb *strings.Builder, title string, keys []map[string]any) { + if len(keys) == 0 { + return + } + renderSection(sb, title, len(keys)) + for i, key := range keys { + apiKey := getString(key, "api-key") + prefix := getString(key, "prefix") + baseURL := getString(key, "base-url") + info := maskKey(apiKey) + if prefix != "" { + info += " (prefix: " + prefix + ")" + } + if baseURL != "" { + info += " โ†’ " + baseURL + } + sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, info)) + } + sb.WriteString("\n") +} + +func maskKey(key string) string { + if len(key) <= 8 { + return strings.Repeat("*", len(key)) + } + return key[:4] + strings.Repeat("*", len(key)-8) + key[len(key)-4:] +} diff --git a/internal/tui/loghook.go b/internal/tui/loghook.go new file mode 100644 index 00000000..157e7fd8 --- /dev/null +++ b/internal/tui/loghook.go @@ -0,0 +1,78 @@ +package tui + +import ( + "fmt" + "strings" + "sync" + + log "github.com/sirupsen/logrus" +) + +// LogHook is a logrus hook that captures log entries and sends them to a channel. +type LogHook struct { + ch chan string + formatter log.Formatter + mu sync.Mutex + levels []log.Level +} + +// NewLogHook creates a new LogHook with a buffered channel of the given size. +func NewLogHook(bufSize int) *LogHook { + return &LogHook{ + ch: make(chan string, bufSize), + formatter: &log.TextFormatter{DisableColors: true, FullTimestamp: true}, + levels: log.AllLevels, + } +} + +// SetFormatter sets a custom formatter for the hook. +func (h *LogHook) SetFormatter(f log.Formatter) { + h.mu.Lock() + defer h.mu.Unlock() + h.formatter = f +} + +// Levels returns the log levels this hook should fire on. +func (h *LogHook) Levels() []log.Level { + return h.levels +} + +// Fire is called by logrus when a log entry is fired. +func (h *LogHook) Fire(entry *log.Entry) error { + h.mu.Lock() + f := h.formatter + h.mu.Unlock() + + var line string + if f != nil { + b, err := f.Format(entry) + if err == nil { + line = strings.TrimRight(string(b), "\n\r") + } else { + line = fmt.Sprintf("[%s] %s", entry.Level, entry.Message) + } + } else { + line = fmt.Sprintf("[%s] %s", entry.Level, entry.Message) + } + + // Non-blocking send + select { + case h.ch <- line: + default: + // Drop oldest if full + select { + case <-h.ch: + default: + } + select { + case h.ch <- line: + default: + } + } + return nil +} + +// Chan returns the channel to read log lines from. +func (h *LogHook) Chan() <-chan string { + return h.ch +} diff --git a/internal/tui/logs_tab.go b/internal/tui/logs_tab.go new file mode 100644 index 00000000..9281d472 --- /dev/null +++ b/internal/tui/logs_tab.go @@ -0,0 +1,195 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" +) + +// logsTabModel displays real-time log lines from the logrus hook. +type logsTabModel struct { + hook *LogHook + viewport viewport.Model + lines []string + maxLines int + autoScroll bool + width int + height int + ready bool + filter string // "", "debug", "info", "warn", "error" +} + +// logLineMsg carries a new log line from the logrus hook channel. +type logLineMsg string + +func newLogsTabModel(hook *LogHook) logsTabModel { + return logsTabModel{ + hook: hook, + maxLines: 5000, + autoScroll: true, + } +} + +func (m logsTabModel) Init() tea.Cmd { + return m.waitForLog +} + +// waitForLog listens on the hook channel and returns a logLineMsg. +func (m logsTabModel) waitForLog() tea.Msg { + line, ok := <-m.hook.Chan() + if !ok { + return nil + } + return logLineMsg(line) +} + +func (m logsTabModel) Update(msg tea.Msg) (logsTabModel, tea.Cmd) { + switch msg := msg.(type) { + case logLineMsg: + m.lines = append(m.lines, string(msg)) + if len(m.lines) > m.maxLines { + m.lines = m.lines[len(m.lines)-m.maxLines:] + } + m.viewport.SetContent(m.renderLogs()) + if m.autoScroll { + m.viewport.GotoBottom() + } + return m, m.waitForLog + + case tea.KeyMsg: + switch msg.String() { + case "a": + m.autoScroll = !m.autoScroll + if m.autoScroll { + m.viewport.GotoBottom() + } + return m, nil + case "c": + m.lines = nil + m.viewport.SetContent(m.renderLogs()) + return m, nil + case "1": + m.filter = "" + m.viewport.SetContent(m.renderLogs()) + return m, nil + case "2": + m.filter = "info" + m.viewport.SetContent(m.renderLogs()) + return m, nil + case "3": + m.filter = "warn" + m.viewport.SetContent(m.renderLogs()) + return m, nil + case "4": + m.filter = "error" + m.viewport.SetContent(m.renderLogs()) + return m, nil + default: + wasAtBottom := m.viewport.AtBottom() + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + // If user scrolls up, disable auto-scroll + if !m.viewport.AtBottom() && wasAtBottom { + m.autoScroll = false + } + // If user scrolls to bottom, re-enable auto-scroll + if m.viewport.AtBottom() { + m.autoScroll = true + } + return m, cmd + } + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m *logsTabModel) SetSize(w, h int) { + m.width = w + m.height = h + if !m.ready { + m.viewport = viewport.New(w, h) + m.viewport.SetContent(m.renderLogs()) + m.ready = true + } else { + m.viewport.Width = w + m.viewport.Height = h + } +} + +func (m logsTabModel) View() string { + if !m.ready { + return "Loading logs..." + } + return m.viewport.View() +} + +func (m logsTabModel) renderLogs() string { + var sb strings.Builder + + scrollStatus := successStyle.Render("โ— AUTO-SCROLL") + if !m.autoScroll { + scrollStatus = warningStyle.Render("โ—‹ PAUSED") + } + filterLabel := "ALL" + if m.filter != "" { + filterLabel = strings.ToUpper(m.filter) + "+" + } + + header := fmt.Sprintf(" ๐Ÿ“‹ Logs %s Filter: %s Lines: %d", + scrollStatus, filterLabel, len(m.lines)) + sb.WriteString(titleStyle.Render(header)) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" [a]uto-scroll โ€ข [c]lear โ€ข [1]all [2]info+ [3]warn+ [4]error โ€ข [โ†‘โ†“] scroll")) + sb.WriteString("\n") + sb.WriteString(strings.Repeat("โ”€", m.width)) + sb.WriteString("\n") + + if len(m.lines) == 0 { + sb.WriteString(subtitleStyle.Render("\n Waiting for log output...")) + return sb.String() + } + + for _, line := range m.lines { + if m.filter != "" && !m.matchLevel(line) { + continue + } + styled := m.styleLine(line) + sb.WriteString(styled) + sb.WriteString("\n") + } + + return sb.String() +} + +func (m logsTabModel) matchLevel(line string) bool { + switch m.filter { + case "error": + return strings.Contains(line, "[error]") || strings.Contains(line, "[fatal]") || strings.Contains(line, "[panic]") + case "warn": + return strings.Contains(line, "[warn") || strings.Contains(line, "[error]") || strings.Contains(line, "[fatal]") + case "info": + return !strings.Contains(line, "[debug]") + default: + return true + } +} + +func (m logsTabModel) styleLine(line string) string { + if strings.Contains(line, "[error]") || strings.Contains(line, "[fatal]") { + return logErrorStyle.Render(line) + } + if strings.Contains(line, "[warn") { + return logWarnStyle.Render(line) + } + if strings.Contains(line, "[info") { + return logInfoStyle.Render(line) + } + if strings.Contains(line, "[debug]") { + return logDebugStyle.Render(line) + } + return line +} diff --git a/internal/tui/oauth_tab.go b/internal/tui/oauth_tab.go new file mode 100644 index 00000000..2f320c2d --- /dev/null +++ b/internal/tui/oauth_tab.go @@ -0,0 +1,470 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// oauthProvider represents an OAuth provider option. +type oauthProvider struct { + name string + apiPath string // management API path + emoji string +} + +var oauthProviders = []oauthProvider{ + {"Gemini CLI", "gemini-cli-auth-url", "๐ŸŸฆ"}, + {"Claude (Anthropic)", "anthropic-auth-url", "๐ŸŸง"}, + {"Codex (OpenAI)", "codex-auth-url", "๐ŸŸฉ"}, + {"Antigravity", "antigravity-auth-url", "๐ŸŸช"}, + {"Qwen", "qwen-auth-url", "๐ŸŸจ"}, + {"Kimi", "kimi-auth-url", "๐ŸŸซ"}, + {"IFlow", "iflow-auth-url", "โฌœ"}, +} + +// oauthTabModel handles OAuth login flows. +type oauthTabModel struct { + client *Client + viewport viewport.Model + cursor int + state oauthState + message string + err error + width int + height int + ready bool + + // Remote browser mode + authURL string // auth URL to display + authState string // OAuth state parameter + providerName string // current provider name + callbackInput textinput.Model + inputActive bool // true when user is typing callback URL +} + +type oauthState int + +const ( + oauthIdle oauthState = iota + oauthPending + oauthRemote // remote browser mode: waiting for manual callback + oauthSuccess + oauthError +) + +// Messages +type oauthStartMsg struct { + url string + state string + providerName string + err error +} + +type oauthPollMsg struct { + done bool + message string + err error +} + +type oauthCallbackSubmitMsg struct { + err error +} + +func newOAuthTabModel(client *Client) oauthTabModel { + ti := textinput.New() + ti.Placeholder = "http://localhost:.../auth/callback?code=...&state=..." + ti.CharLimit = 2048 + ti.Prompt = " ๅ›ž่ฐƒ URL: " + return oauthTabModel{ + client: client, + callbackInput: ti, + } +} + +func (m oauthTabModel) Init() tea.Cmd { + return nil +} + +func (m oauthTabModel) Update(msg tea.Msg) (oauthTabModel, tea.Cmd) { + switch msg := msg.(type) { + case oauthStartMsg: + if msg.err != nil { + m.state = oauthError + m.err = msg.err + m.message = errorStyle.Render("โœ— " + msg.err.Error()) + m.viewport.SetContent(m.renderContent()) + return m, nil + } + m.authURL = msg.url + m.authState = msg.state + m.providerName = msg.providerName + m.state = oauthRemote + m.callbackInput.SetValue("") + m.callbackInput.Focus() + m.inputActive = true + m.message = "" + m.viewport.SetContent(m.renderContent()) + // Also start polling in the background + return m, tea.Batch(textinput.Blink, m.pollOAuthStatus(msg.state)) + + case oauthPollMsg: + if msg.err != nil { + m.state = oauthError + m.err = msg.err + m.message = errorStyle.Render("โœ— " + msg.err.Error()) + m.inputActive = false + m.callbackInput.Blur() + } else if msg.done { + m.state = oauthSuccess + m.message = successStyle.Render("โœ“ " + msg.message) + m.inputActive = false + m.callbackInput.Blur() + } else { + m.message = warningStyle.Render("โณ " + msg.message) + } + m.viewport.SetContent(m.renderContent()) + return m, nil + + case oauthCallbackSubmitMsg: + if msg.err != nil { + m.message = errorStyle.Render("โœ— ๆไบคๅ›ž่ฐƒๅคฑ่ดฅ: " + msg.err.Error()) + } else { + m.message = successStyle.Render("โœ“ ๅ›ž่ฐƒๅทฒๆไบค๏ผŒ็ญ‰ๅพ…ๅค„็†...") + } + m.viewport.SetContent(m.renderContent()) + return m, nil + + case tea.KeyMsg: + // ---- Input active: typing callback URL ---- + if m.inputActive { + switch msg.String() { + case "enter": + callbackURL := m.callbackInput.Value() + if callbackURL == "" { + return m, nil + } + m.inputActive = false + m.callbackInput.Blur() + m.message = warningStyle.Render("โณ ๆไบคๅ›ž่ฐƒไธญ...") + m.viewport.SetContent(m.renderContent()) + return m, m.submitCallback(callbackURL) + case "esc": + m.inputActive = false + m.callbackInput.Blur() + m.viewport.SetContent(m.renderContent()) + return m, nil + default: + var cmd tea.Cmd + m.callbackInput, cmd = m.callbackInput.Update(msg) + m.viewport.SetContent(m.renderContent()) + return m, cmd + } + } + + // ---- Remote mode but not typing ---- + if m.state == oauthRemote { + switch msg.String() { + case "c", "C": + // Re-activate input + m.inputActive = true + m.callbackInput.Focus() + m.viewport.SetContent(m.renderContent()) + return m, textinput.Blink + case "esc": + m.state = oauthIdle + m.message = "" + m.authURL = "" + m.authState = "" + m.viewport.SetContent(m.renderContent()) + return m, nil + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + + // ---- Pending (auto polling) ---- + if m.state == oauthPending { + if msg.String() == "esc" { + m.state = oauthIdle + m.message = "" + m.viewport.SetContent(m.renderContent()) + } + return m, nil + } + + // ---- Idle ---- + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + m.viewport.SetContent(m.renderContent()) + } + return m, nil + case "down", "j": + if m.cursor < len(oauthProviders)-1 { + m.cursor++ + m.viewport.SetContent(m.renderContent()) + } + return m, nil + case "enter": + if m.cursor >= 0 && m.cursor < len(oauthProviders) { + provider := oauthProviders[m.cursor] + m.state = oauthPending + m.message = warningStyle.Render("โณ ๆญฃๅœจๅˆๅง‹ๅŒ– " + provider.name + " ็™ปๅฝ•...") + m.viewport.SetContent(m.renderContent()) + return m, m.startOAuth(provider) + } + return m, nil + case "esc": + m.state = oauthIdle + m.message = "" + m.err = nil + m.viewport.SetContent(m.renderContent()) + return m, nil + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m oauthTabModel) startOAuth(provider oauthProvider) tea.Cmd { + return func() tea.Msg { + // Call the auth URL endpoint with is_webui=true + data, err := m.client.getJSON("/v0/management/" + provider.apiPath + "?is_webui=true") + if err != nil { + return oauthStartMsg{err: fmt.Errorf("failed to start %s login: %w", provider.name, err)} + } + + authURL := getString(data, "url") + state := getString(data, "state") + if authURL == "" { + return oauthStartMsg{err: fmt.Errorf("no auth URL returned for %s", provider.name)} + } + + // Try to open browser (best effort) + _ = openBrowser(authURL) + + return oauthStartMsg{url: authURL, state: state, providerName: provider.name} + } +} + +func (m oauthTabModel) submitCallback(callbackURL string) tea.Cmd { + return func() tea.Msg { + // Determine provider from current context + providerKey := "" + for _, p := range oauthProviders { + if p.name == m.providerName { + // Map provider name to the canonical key the API expects + switch p.apiPath { + case "gemini-cli-auth-url": + providerKey = "gemini" + case "anthropic-auth-url": + providerKey = "anthropic" + case "codex-auth-url": + providerKey = "codex" + case "antigravity-auth-url": + providerKey = "antigravity" + case "qwen-auth-url": + providerKey = "qwen" + case "kimi-auth-url": + providerKey = "kimi" + case "iflow-auth-url": + providerKey = "iflow" + } + break + } + } + + body := map[string]string{ + "provider": providerKey, + "redirect_url": callbackURL, + "state": m.authState, + } + err := m.client.postJSON("/v0/management/oauth-callback", body) + if err != nil { + return oauthCallbackSubmitMsg{err: err} + } + return oauthCallbackSubmitMsg{} + } +} + +func (m oauthTabModel) pollOAuthStatus(state string) tea.Cmd { + return func() tea.Msg { + // Poll session status for up to 5 minutes + deadline := time.Now().Add(5 * time.Minute) + for { + if time.Now().After(deadline) { + return oauthPollMsg{done: false, err: fmt.Errorf("OAuth flow timed out (5 minutes)")} + } + + time.Sleep(2 * time.Second) + + status, errMsg, err := m.client.GetAuthStatus(state) + if err != nil { + continue // Ignore transient errors + } + + switch status { + case "ok": + return oauthPollMsg{ + done: true, + message: "่ฎค่ฏๆˆๅŠŸ! ่ฏทๅˆทๆ–ฐ Auth Files ๆ ‡็ญพๆŸฅ็œ‹ๆ–ฐๅ‡ญ่ฏใ€‚", + } + case "error": + return oauthPollMsg{ + done: false, + err: fmt.Errorf("่ฎค่ฏๅคฑ่ดฅ: %s", errMsg), + } + case "wait": + continue + default: + return oauthPollMsg{ + done: true, + message: "่ฎค่ฏๆต็จ‹ๅทฒๅฎŒๆˆใ€‚", + } + } + } + } +} + +func (m *oauthTabModel) SetSize(w, h int) { + m.width = w + m.height = h + m.callbackInput.Width = w - 16 + if !m.ready { + m.viewport = viewport.New(w, h) + m.viewport.SetContent(m.renderContent()) + m.ready = true + } else { + m.viewport.Width = w + m.viewport.Height = h + } +} + +func (m oauthTabModel) View() string { + if !m.ready { + return "Loading..." + } + return m.viewport.View() +} + +func (m oauthTabModel) renderContent() string { + var sb strings.Builder + + sb.WriteString(titleStyle.Render("๐Ÿ” OAuth ็™ปๅฝ•")) + sb.WriteString("\n\n") + + if m.message != "" { + sb.WriteString(" " + m.message) + sb.WriteString("\n\n") + } + + // ---- Remote browser mode ---- + if m.state == oauthRemote { + sb.WriteString(m.renderRemoteMode()) + return sb.String() + } + + if m.state == oauthPending { + sb.WriteString(helpStyle.Render(" Press [Esc] to cancel")) + return sb.String() + } + + sb.WriteString(helpStyle.Render(" ้€‰ๆ‹ฉๆไพ›ๅ•†ๅนถๆŒ‰ [Enter] ๅผ€ๅง‹ OAuth ็™ปๅฝ•:")) + sb.WriteString("\n\n") + + for i, p := range oauthProviders { + isSelected := i == m.cursor + prefix := " " + if isSelected { + prefix = "โ–ธ " + } + + label := fmt.Sprintf("%s %s", p.emoji, p.name) + if isSelected { + label = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(colorPrimary).Padding(0, 1).Render(label) + } else { + label = lipgloss.NewStyle().Foreground(colorText).Padding(0, 1).Render(label) + } + + sb.WriteString(prefix + label + "\n") + } + + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" [โ†‘โ†“/jk] ๅฏผ่ˆช โ€ข [Enter] ็™ปๅฝ• โ€ข [Esc] ๆธ…้™ค็Šถๆ€")) + + return sb.String() +} + +func (m oauthTabModel) renderRemoteMode() string { + var sb strings.Builder + + providerStyle := lipgloss.NewStyle().Bold(true).Foreground(colorHighlight) + sb.WriteString(providerStyle.Render(fmt.Sprintf(" โœฆ %s OAuth", m.providerName))) + sb.WriteString("\n\n") + + // Auth URL section + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(" ๆŽˆๆƒ้“พๆŽฅ:")) + sb.WriteString("\n") + + // Wrap URL to fit terminal width + urlStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + maxURLWidth := m.width - 6 + if maxURLWidth < 40 { + maxURLWidth = 40 + } + wrappedURL := wrapText(m.authURL, maxURLWidth) + for _, line := range wrappedURL { + sb.WriteString(" " + urlStyle.Render(line) + "\n") + } + sb.WriteString("\n") + + sb.WriteString(helpStyle.Render(" ่ฟœ็จ‹ๆต่งˆๅ™จๆจกๅผ๏ผšๅœจๆต่งˆๅ™จไธญๆ‰“ๅผ€ไธŠ่ฟฐ้“พๆŽฅๅฎŒๆˆๆŽˆๆƒๅŽ๏ผŒๅฐ†ๅ›ž่ฐƒ URL ็ฒ˜่ดดๅˆฐไธ‹ๆ–นใ€‚")) + sb.WriteString("\n\n") + + // Callback URL input + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorInfo).Render(" ๅ›ž่ฐƒ URL:")) + sb.WriteString("\n") + + if m.inputActive { + sb.WriteString(m.callbackInput.View()) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" Enter: ๆไบค โ€ข Esc: ๅ–ๆถˆ่พ“ๅ…ฅ")) + } else { + sb.WriteString(helpStyle.Render(" ๆŒ‰ [c] ่พ“ๅ…ฅๅ›ž่ฐƒ URL โ€ข [Esc] ่ฟ”ๅ›ž")) + } + + sb.WriteString("\n\n") + sb.WriteString(warningStyle.Render(" ็ญ‰ๅพ…่ฎค่ฏไธญ...")) + + return sb.String() +} + +// wrapText splits a long string into lines of at most maxWidth characters. +func wrapText(s string, maxWidth int) []string { + if maxWidth <= 0 { + return []string{s} + } + var lines []string + for len(s) > maxWidth { + lines = append(lines, s[:maxWidth]) + s = s[maxWidth:] + } + if len(s) > 0 { + lines = append(lines, s) + } + return lines +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 00000000..f09e4322 --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,126 @@ +// Package tui provides a terminal-based management interface for CLIProxyAPI. +package tui + +import "github.com/charmbracelet/lipgloss" + +// Color palette +var ( + colorPrimary = lipgloss.Color("#7C3AED") // violet + colorSecondary = lipgloss.Color("#6366F1") // indigo + colorSuccess = lipgloss.Color("#22C55E") // green + colorWarning = lipgloss.Color("#EAB308") // yellow + colorError = lipgloss.Color("#EF4444") // red + colorInfo = lipgloss.Color("#3B82F6") // blue + colorMuted = lipgloss.Color("#6B7280") // gray + colorBg = lipgloss.Color("#1E1E2E") // dark bg + colorSurface = lipgloss.Color("#313244") // slightly lighter + colorText = lipgloss.Color("#CDD6F4") // light text + colorSubtext = lipgloss.Color("#A6ADC8") // dimmer text + colorBorder = lipgloss.Color("#45475A") // border + colorHighlight = lipgloss.Color("#F5C2E7") // pink highlight +) + +// Tab bar styles +var ( + tabActiveStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(colorPrimary). + Padding(0, 2) + + tabInactiveStyle = lipgloss.NewStyle(). + Foreground(colorSubtext). + Background(colorSurface). + Padding(0, 2) + + tabBarStyle = lipgloss.NewStyle(). + Background(colorSurface). + PaddingLeft(1). + PaddingBottom(0) +) + +// Content styles +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorHighlight). + MarginBottom(1) + + subtitleStyle = lipgloss.NewStyle(). + Foreground(colorSubtext). + Italic(true) + + labelStyle = lipgloss.NewStyle(). + Foreground(colorInfo). + Bold(true). + Width(24) + + valueStyle = lipgloss.NewStyle(). + Foreground(colorText) + + sectionStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorBorder). + Padding(1, 2) + + errorStyle = lipgloss.NewStyle(). + Foreground(colorError). + Bold(true) + + successStyle = lipgloss.NewStyle(). + Foreground(colorSuccess) + + warningStyle = lipgloss.NewStyle(). + Foreground(colorWarning) + + statusBarStyle = lipgloss.NewStyle(). + Foreground(colorSubtext). + Background(colorSurface). + PaddingLeft(1). + PaddingRight(1) + + helpStyle = lipgloss.NewStyle(). + Foreground(colorMuted) +) + +// Log level styles +var ( + logDebugStyle = lipgloss.NewStyle().Foreground(colorMuted) + logInfoStyle = lipgloss.NewStyle().Foreground(colorInfo) + logWarnStyle = lipgloss.NewStyle().Foreground(colorWarning) + logErrorStyle = lipgloss.NewStyle().Foreground(colorError) +) + +// Table styles +var ( + tableHeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorHighlight). + BorderBottom(true). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(colorBorder) + + tableCellStyle = lipgloss.NewStyle(). + Foreground(colorText). + PaddingRight(2) + + tableSelectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Background(colorPrimary). + Bold(true) +) + +func logLevelStyle(level string) lipgloss.Style { + switch level { + case "debug": + return logDebugStyle + case "info": + return logInfoStyle + case "warn", "warning": + return logWarnStyle + case "error", "fatal", "panic": + return logErrorStyle + default: + return logInfoStyle + } +} diff --git a/internal/tui/usage_tab.go b/internal/tui/usage_tab.go new file mode 100644 index 00000000..ebbf832d --- /dev/null +++ b/internal/tui/usage_tab.go @@ -0,0 +1,361 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// usageTabModel displays usage statistics with charts and breakdowns. +type usageTabModel struct { + client *Client + viewport viewport.Model + usage map[string]any + err error + width int + height int + ready bool +} + +type usageDataMsg struct { + usage map[string]any + err error +} + +func newUsageTabModel(client *Client) usageTabModel { + return usageTabModel{ + client: client, + } +} + +func (m usageTabModel) Init() tea.Cmd { + return m.fetchData +} + +func (m usageTabModel) fetchData() tea.Msg { + usage, err := m.client.GetUsage() + return usageDataMsg{usage: usage, err: err} +} + +func (m usageTabModel) Update(msg tea.Msg) (usageTabModel, tea.Cmd) { + switch msg := msg.(type) { + case usageDataMsg: + if msg.err != nil { + m.err = msg.err + } else { + m.err = nil + m.usage = msg.usage + } + m.viewport.SetContent(m.renderContent()) + return m, nil + + case tea.KeyMsg: + if msg.String() == "r" { + return m, m.fetchData + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m *usageTabModel) SetSize(w, h int) { + m.width = w + m.height = h + if !m.ready { + m.viewport = viewport.New(w, h) + m.viewport.SetContent(m.renderContent()) + m.ready = true + } else { + m.viewport.Width = w + m.viewport.Height = h + } +} + +func (m usageTabModel) View() string { + if !m.ready { + return "Loading..." + } + return m.viewport.View() +} + +func (m usageTabModel) renderContent() string { + var sb strings.Builder + + sb.WriteString(titleStyle.Render("๐Ÿ“ˆ ไฝฟ็”จ็ปŸ่ฎก")) + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" [r] refresh โ€ข [โ†‘โ†“] scroll")) + sb.WriteString("\n\n") + + if m.err != nil { + sb.WriteString(errorStyle.Render("โš  Error: " + m.err.Error())) + sb.WriteString("\n") + return sb.String() + } + + if m.usage == nil { + sb.WriteString(subtitleStyle.Render(" Usage data not available")) + sb.WriteString("\n") + return sb.String() + } + + usageMap, _ := m.usage["usage"].(map[string]any) + if usageMap == nil { + sb.WriteString(subtitleStyle.Render(" No usage data")) + sb.WriteString("\n") + return sb.String() + } + + totalReqs := int64(getFloat(usageMap, "total_requests")) + successCnt := int64(getFloat(usageMap, "success_count")) + failureCnt := int64(getFloat(usageMap, "failure_count")) + totalTokens := int64(getFloat(usageMap, "total_tokens")) + + // โ”โ”โ” Overview Cards โ”โ”โ” + cardWidth := 20 + if m.width > 0 { + cardWidth = (m.width - 6) / 4 + if cardWidth < 16 { + cardWidth = 16 + } + } + cardStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("240")). + Padding(0, 1). + Width(cardWidth). + Height(3) + + // Total Requests + card1 := cardStyle.Copy().BorderForeground(lipgloss.Color("111")).Render(fmt.Sprintf( + "%s\n%s\n%s", + lipgloss.NewStyle().Foreground(colorMuted).Render("ๆ€ป่ฏทๆฑ‚ๆ•ฐ"), + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")).Render(fmt.Sprintf("%d", totalReqs)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("โ— ๆˆๅŠŸ: %d โ— ๅคฑ่ดฅ: %d", successCnt, failureCnt)), + )) + + // Total Tokens + card2 := cardStyle.Copy().BorderForeground(lipgloss.Color("214")).Render(fmt.Sprintf( + "%s\n%s\n%s", + lipgloss.NewStyle().Foreground(colorMuted).Render("ๆ€ป Token ๆ•ฐ"), + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(formatLargeNumber(totalTokens)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("ๆ€ปToken: %s", formatLargeNumber(totalTokens))), + )) + + // RPM + rpm := float64(0) + if totalReqs > 0 { + if rByH, ok := usageMap["requests_by_hour"].(map[string]any); ok && len(rByH) > 0 { + rpm = float64(totalReqs) / float64(len(rByH)) / 60.0 + } + } + card3 := cardStyle.Copy().BorderForeground(lipgloss.Color("76")).Render(fmt.Sprintf( + "%s\n%s\n%s", + lipgloss.NewStyle().Foreground(colorMuted).Render("RPM"), + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("76")).Render(fmt.Sprintf("%.2f", rpm)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("ๆ€ป่ฏทๆฑ‚ๆ•ฐ: %d", totalReqs)), + )) + + // TPM + tpm := float64(0) + if totalTokens > 0 { + if tByH, ok := usageMap["tokens_by_hour"].(map[string]any); ok && len(tByH) > 0 { + tpm = float64(totalTokens) / float64(len(tByH)) / 60.0 + } + } + card4 := cardStyle.Copy().BorderForeground(lipgloss.Color("170")).Render(fmt.Sprintf( + "%s\n%s\n%s", + lipgloss.NewStyle().Foreground(colorMuted).Render("TPM"), + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("%.2f", tpm)), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("ๆ€ปTokenๆ•ฐ: %s", formatLargeNumber(totalTokens))), + )) + + sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2, " ", card3, " ", card4)) + sb.WriteString("\n\n") + + // โ”โ”โ” Requests by Hour (ASCII bar chart) โ”โ”โ” + if rByH, ok := usageMap["requests_by_hour"].(map[string]any); ok && len(rByH) > 0 { + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("่ฏทๆฑ‚่ถ‹ๅŠฟ (ๆŒ‰ๅฐๆ—ถ)")) + sb.WriteString("\n") + sb.WriteString(strings.Repeat("โ”€", minInt(m.width, 60))) + sb.WriteString("\n") + sb.WriteString(renderBarChart(rByH, m.width-6, lipgloss.Color("111"))) + sb.WriteString("\n") + } + + // โ”โ”โ” Tokens by Hour โ”โ”โ” + if tByH, ok := usageMap["tokens_by_hour"].(map[string]any); ok && len(tByH) > 0 { + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("Token ไฝฟ็”จ่ถ‹ๅŠฟ (ๆŒ‰ๅฐๆ—ถ)")) + sb.WriteString("\n") + sb.WriteString(strings.Repeat("โ”€", minInt(m.width, 60))) + sb.WriteString("\n") + sb.WriteString(renderBarChart(tByH, m.width-6, lipgloss.Color("214"))) + sb.WriteString("\n") + } + + // โ”โ”โ” Requests by Day โ”โ”โ” + if rByD, ok := usageMap["requests_by_day"].(map[string]any); ok && len(rByD) > 0 { + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("่ฏทๆฑ‚่ถ‹ๅŠฟ (ๆŒ‰ๅคฉ)")) + sb.WriteString("\n") + sb.WriteString(strings.Repeat("โ”€", minInt(m.width, 60))) + sb.WriteString("\n") + sb.WriteString(renderBarChart(rByD, m.width-6, lipgloss.Color("76"))) + sb.WriteString("\n") + } + + // โ”โ”โ” API Detail Stats โ”โ”โ” + if apis, ok := usageMap["apis"].(map[string]any); ok && len(apis) > 0 { + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render("API ่ฏฆ็ป†็ปŸ่ฎก")) + sb.WriteString("\n") + sb.WriteString(strings.Repeat("โ”€", minInt(m.width, 80))) + sb.WriteString("\n") + + header := fmt.Sprintf(" %-30s %10s %12s", "API", "Requests", "Tokens") + sb.WriteString(tableHeaderStyle.Render(header)) + sb.WriteString("\n") + + for apiName, apiSnap := range apis { + if apiMap, ok := apiSnap.(map[string]any); ok { + apiReqs := int64(getFloat(apiMap, "total_requests")) + apiToks := int64(getFloat(apiMap, "total_tokens")) + + row := fmt.Sprintf(" %-30s %10d %12s", + truncate(apiName, 30), apiReqs, formatLargeNumber(apiToks)) + sb.WriteString(lipgloss.NewStyle().Bold(true).Render(row)) + sb.WriteString("\n") + + // Per-model breakdown + if models, ok := apiMap["models"].(map[string]any); ok { + for model, v := range models { + if stats, ok := v.(map[string]any); ok { + mReqs := int64(getFloat(stats, "total_requests")) + mToks := int64(getFloat(stats, "total_tokens")) + mRow := fmt.Sprintf(" โ”œโ”€ %-28s %10d %12s", + truncate(model, 28), mReqs, formatLargeNumber(mToks)) + sb.WriteString(tableCellStyle.Render(mRow)) + sb.WriteString("\n") + + // Token type breakdown from details + sb.WriteString(m.renderTokenBreakdown(stats)) + } + } + } + } + } + } + + sb.WriteString("\n") + return sb.String() +} + +// renderTokenBreakdown aggregates input/output/cached/reasoning tokens from model details. +func (m usageTabModel) renderTokenBreakdown(modelStats map[string]any) string { + details, ok := modelStats["details"] + if !ok { + return "" + } + detailList, ok := details.([]any) + if !ok || len(detailList) == 0 { + return "" + } + + var inputTotal, outputTotal, cachedTotal, reasoningTotal int64 + for _, d := range detailList { + dm, ok := d.(map[string]any) + if !ok { + continue + } + tokens, ok := dm["tokens"].(map[string]any) + if !ok { + continue + } + inputTotal += int64(getFloat(tokens, "input_tokens")) + outputTotal += int64(getFloat(tokens, "output_tokens")) + cachedTotal += int64(getFloat(tokens, "cached_tokens")) + reasoningTotal += int64(getFloat(tokens, "reasoning_tokens")) + } + + if inputTotal == 0 && outputTotal == 0 && cachedTotal == 0 && reasoningTotal == 0 { + return "" + } + + parts := []string{} + if inputTotal > 0 { + parts = append(parts, fmt.Sprintf("่พ“ๅ…ฅ:%s", formatLargeNumber(inputTotal))) + } + if outputTotal > 0 { + parts = append(parts, fmt.Sprintf("่พ“ๅ‡บ:%s", formatLargeNumber(outputTotal))) + } + if cachedTotal > 0 { + parts = append(parts, fmt.Sprintf("็ผ“ๅญ˜:%s", formatLargeNumber(cachedTotal))) + } + if reasoningTotal > 0 { + parts = append(parts, fmt.Sprintf("ๆ€่€ƒ:%s", formatLargeNumber(reasoningTotal))) + } + + return fmt.Sprintf(" โ”‚ %s\n", + lipgloss.NewStyle().Foreground(colorMuted).Render(strings.Join(parts, " "))) +} + +// renderBarChart renders a simple ASCII horizontal bar chart. +func renderBarChart(data map[string]any, maxBarWidth int, barColor lipgloss.Color) string { + if maxBarWidth < 10 { + maxBarWidth = 10 + } + + // Sort keys + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) + + // Find max value + maxVal := float64(0) + for _, k := range keys { + v := getFloat(data, k) + if v > maxVal { + maxVal = v + } + } + if maxVal == 0 { + return "" + } + + barStyle := lipgloss.NewStyle().Foreground(barColor) + var sb strings.Builder + + labelWidth := 12 + barAvail := maxBarWidth - labelWidth - 12 + if barAvail < 5 { + barAvail = 5 + } + + for _, k := range keys { + v := getFloat(data, k) + barLen := int(v / maxVal * float64(barAvail)) + if barLen < 1 && v > 0 { + barLen = 1 + } + bar := strings.Repeat("โ–ˆ", barLen) + label := k + if len(label) > labelWidth { + label = label[:labelWidth] + } + sb.WriteString(fmt.Sprintf(" %-*s %s %s\n", + labelWidth, label, + barStyle.Render(bar), + lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%.0f", v)), + )) + } + + return sb.String() +}