From 998b456b6529cd30ed584ec18614b029f454a2e4 Mon Sep 17 00:00:00 2001 From: taorye Date: Tue, 10 Mar 2026 16:51:26 +0800 Subject: [PATCH 01/10] Remove UI components and gateway management for picoclaw-launcher-tui - Deleted channel management UI from channel.go, including all associated forms and menu items. - Removed platform-specific gateway process management from gateway_posix.go and gateway_windows.go. - Eliminated menu structure and item management from menu.go. - Removed model management and configuration handling from model.go. - Deleted style definitions and application logic from style.go. - Cleared main entry point in main.go. --- .../internal/config/store.go | 49 -- cmd/picoclaw-launcher-tui/internal/ui/app.go | 522 ------------------ .../internal/ui/channel.go | 433 --------------- .../internal/ui/gateway_posix.go | 16 - .../internal/ui/gateway_windows.go | 16 - cmd/picoclaw-launcher-tui/internal/ui/menu.go | 72 --- .../internal/ui/model.go | 399 ------------- .../internal/ui/style.go | 55 -- cmd/picoclaw-launcher-tui/main.go | 15 - 9 files changed, 1577 deletions(-) delete mode 100644 cmd/picoclaw-launcher-tui/internal/config/store.go delete mode 100644 cmd/picoclaw-launcher-tui/internal/ui/app.go delete mode 100644 cmd/picoclaw-launcher-tui/internal/ui/channel.go delete mode 100644 cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go delete mode 100644 cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go delete mode 100644 cmd/picoclaw-launcher-tui/internal/ui/menu.go delete mode 100644 cmd/picoclaw-launcher-tui/internal/ui/model.go delete mode 100644 cmd/picoclaw-launcher-tui/internal/ui/style.go delete mode 100644 cmd/picoclaw-launcher-tui/main.go diff --git a/cmd/picoclaw-launcher-tui/internal/config/store.go b/cmd/picoclaw-launcher-tui/internal/config/store.go deleted file mode 100644 index 0236de19f..000000000 --- a/cmd/picoclaw-launcher-tui/internal/config/store.go +++ /dev/null @@ -1,49 +0,0 @@ -package configstore - -import ( - "errors" - "os" - "path/filepath" - - picoclawconfig "github.com/sipeed/picoclaw/pkg/config" -) - -const ( - configDirName = ".picoclaw" - configFileName = "config.json" -) - -func ConfigPath() (string, error) { - dir, err := ConfigDir() - if err != nil { - return "", err - } - return filepath.Join(dir, configFileName), nil -} - -func ConfigDir() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, configDirName), nil -} - -func Load() (*picoclawconfig.Config, error) { - path, err := ConfigPath() - if err != nil { - return nil, err - } - return picoclawconfig.LoadConfig(path) -} - -func Save(cfg *picoclawconfig.Config) error { - if cfg == nil { - return errors.New("config is nil") - } - path, err := ConfigPath() - if err != nil { - return err - } - return picoclawconfig.SaveConfig(path, cfg) -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/app.go b/cmd/picoclaw-launcher-tui/internal/ui/app.go deleted file mode 100644 index a2ccddf70..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/app.go +++ /dev/null @@ -1,522 +0,0 @@ -package ui - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - configstore "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config" - picoclawconfig "github.com/sipeed/picoclaw/pkg/config" -) - -type appState struct { - app *tview.Application - pages *tview.Pages - stack []string - config *picoclawconfig.Config - configPath string - gatewayCmd *exec.Cmd - menus map[string]*Menu - original []byte - hasOriginal bool - backupPath string - dirty bool - logPath string -} - -func Run() error { - applyStyles() - cfg, err := configstore.Load() - if err != nil { - return err - } - path, err := configstore.ConfigPath() - if err != nil { - return err - } - - if cfg == nil { - cfg = picoclawconfig.DefaultConfig() - } - - originalData, hasOriginal := loadOriginalConfig(path) - backupPath := path + ".bak" - if hasOriginal { - _ = writeBackupConfig(backupPath, originalData) - } - - logPath := filepath.Join(filepath.Dir(path), "gateway.log") - state := &appState{ - app: tview.NewApplication(), - pages: tview.NewPages(), - config: cfg, - configPath: path, - menus: map[string]*Menu{}, - original: originalData, - hasOriginal: hasOriginal, - backupPath: backupPath, - logPath: logPath, - } - - state.push("main", state.mainMenu()) - - root := tview.NewFlex().SetDirection(tview.FlexRow) - root.AddItem(bannerView(), 6, 0, false) - root.AddItem(state.pages, 0, 1, true) - root.AddItem(footerView(), 1, 0, false) - - if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil { - return err - } - return nil -} - -func (s *appState) push(name string, primitive tview.Primitive) { - s.pages.AddPage(name, primitive, true, true) - s.stack = append(s.stack, name) - s.pages.SwitchToPage(name) - if menu, ok := primitive.(*Menu); ok { - s.menus[name] = menu - } -} - -func (s *appState) pop() { - if len(s.stack) == 0 { - return - } - last := s.stack[len(s.stack)-1] - s.pages.RemovePage(last) - s.stack = s.stack[:len(s.stack)-1] - if len(s.stack) == 0 { - s.app.Stop() - return - } - current := s.stack[len(s.stack)-1] - s.pages.SwitchToPage(current) - if menu, ok := s.menus[current]; ok { - s.refreshMenu(current, menu) - } -} - -func (s *appState) mainMenu() tview.Primitive { - menu := NewMenu("Menu", nil) - refreshMainMenu(menu, s) - menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyEsc: - s.requestExit() - return nil - } - - return event - }) - - return menu -} - -func (s *appState) refreshMenu(name string, menu *Menu) { - switch name { - case "main": - refreshMainMenu(menu, s) - case "model": - refreshModelMenuFromState(menu, s) - case "channel": - refreshChannelMenuFromState(menu, s) - } -} - -func (s *appState) countChannels() (enabled int, total int) { - c := s.config.Channels - entries := []bool{ - c.Telegram.Enabled, - c.Discord.Enabled, - c.QQ.Enabled, - c.MaixCam.Enabled, - c.WhatsApp.Enabled, - c.Feishu.Enabled, - c.DingTalk.Enabled, - c.Slack.Enabled, - c.Matrix.Enabled, - c.LINE.Enabled, - c.OneBot.Enabled, - c.WeCom.Enabled, - c.WeComApp.Enabled, - } - total = len(entries) - for _, v := range entries { - if v { - enabled++ - } - } - return enabled, total -} - -func refreshMainMenuIfPresent(s *appState) { - if menu, ok := s.menus["main"]; ok { - refreshMainMenu(menu, s) - } -} - -func refreshMainMenu(menu *Menu, s *appState) { - selectedModel := s.selectedModelName() - modelReady := selectedModel != "" - channelReady := s.hasEnabledChannel() - enabledCount, totalChannels := s.countChannels() - gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning() - - gatewayLabel := "Start Gateway" - gatewayDescription := "Launch gateway for channels" - if gatewayRunning { - gatewayLabel = "Stop Gateway" - gatewayDescription = "Gateway running" - } - - items := []MenuItem{ - { - Label: rootModelLabel(selectedModel), - Description: rootModelDescription(), - Action: func() { - s.push("model", s.modelMenu()) - }, - MainColor: func() *tcell.Color { - if modelReady { - return nil - } - color := tcell.ColorGray - return &color - }(), - }, - { - Label: rootChannelLabel(channelReady), - Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels), - Action: func() { - s.push("channel", s.channelMenu()) - }, - MainColor: func() *tcell.Color { - if channelReady { - return nil - } - color := tcell.ColorGray - return &color - }(), - }, - { - Label: "Start Talk", - Description: "Open picoclaw agent in terminal", - Action: func() { - s.requestStartTalk() - }, - Disabled: !modelReady, - }, - { - Label: gatewayLabel, - Description: gatewayDescription, - Action: func() { - if gatewayRunning { - s.stopGateway() - } else { - s.requestStartGateway() - } - refreshMainMenu(menu, s) - }, - Disabled: !gatewayRunning && (!modelReady || !channelReady), - }, - { - Label: "View Gateway Log", - Description: "Open gateway.log", - Action: func() { - s.viewGatewayLog() - }, - }, - { - Label: "Exit", - Description: "Exit the TUI", - Action: func() { - s.requestExit() - }, - }, - } - menu.applyItems(items) -} - -func (s *appState) applyChangesValidated() bool { - if err := s.config.ValidateModelList(); err != nil { - s.showMessage("Validation failed", err.Error()) - return false - } - if err := s.validateAgentModel(); err != nil { - s.showMessage("Validation failed", err.Error()) - return false - } - if err := configstore.Save(s.config); err != nil { - s.showMessage("Save failed", err.Error()) - return false - } - if data, err := os.ReadFile(s.configPath); err == nil { - s.original = data - s.hasOriginal = true - _ = writeBackupConfig(s.backupPath, data) - } - return true -} - -func (s *appState) requestExit() { - if s.dirty { - s.confirmApplyOrDiscard(func() { - s.app.Stop() - }, func() { - s.discardChanges() - s.app.Stop() - }) - return - } - s.app.Stop() -} - -func (s *appState) requestStartTalk() { - if s.dirty { - s.confirmApplyOrDiscard(func() { - s.startTalk() - }, func() { - s.startTalk() - }) - return - } - s.startTalk() -} - -func (s *appState) requestStartGateway() { - if s.dirty { - s.confirmApplyOrDiscard(func() { - s.startGateway() - }, func() { - s.startGateway() - }) - return - } - s.startGateway() -} - -func (s *appState) viewGatewayLog() { - data, err := os.ReadFile(s.logPath) - if err != nil { - s.showMessage("Log not found", "gateway.log not found") - return - } - text := tview.NewTextView() - text.SetBorder(true).SetTitle("Gateway Log") - text.SetText(string(data)) - text.SetDoneFunc(func(key tcell.Key) { - s.pages.RemovePage("log") - }) - text.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - s.pages.RemovePage("log") - return nil - } - return event - }) - s.pages.AddPage("log", text, true, true) -} - -func (s *appState) selectedModelName() string { - modelName := strings.TrimSpace(s.config.Agents.Defaults.Model) - if modelName == "" { - return "" - } - if !s.isActiveModelValid() { - return "" - } - return modelName -} - -func rootModelLabel(selected string) string { - if selected == "" { - return "Model (None)" - } - return "Model (" + selected + ")" -} - -func rootModelDescription() string { - return "Using SPACE to choose your model" -} - -func rootChannelLabel(valid bool) string { - if !valid { - return "Channel (no channel enabled)" - } - return "Channel" -} - -func (s *appState) startTalk() { - if !s.isActiveModelValid() { - s.showMessage("Model required", "Select a valid model before starting talk") - return - } - if !s.applyChangesValidated() { - return - } - s.app.Suspend(func() { - cmd := exec.Command("picoclaw", "agent") - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - _ = cmd.Run() - }) -} - -func (s *appState) startGateway() { - if !s.isActiveModelValid() { - s.showMessage("Model required", "Select a valid model before starting gateway") - return - } - if !s.hasEnabledChannel() { - s.showMessage("Channel required", "Enable at least one channel before starting gateway") - return - } - if !s.applyChangesValidated() { - return - } - _ = stopGatewayProcess() - cmd := exec.Command("picoclaw", "gateway") - logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) - if err != nil { - s.showMessage("Gateway failed", err.Error()) - return - } - cmd.Stdout = logFile - cmd.Stderr = logFile - if err := cmd.Start(); err != nil { - s.showMessage("Gateway failed", err.Error()) - _ = logFile.Close() - return - } - _ = logFile.Close() - s.gatewayCmd = cmd -} - -func (s *appState) stopGateway() { - _ = stopGatewayProcess() - if s.gatewayCmd != nil && s.gatewayCmd.Process != nil { - _ = s.gatewayCmd.Process.Kill() - } - s.gatewayCmd = nil -} - -func (s *appState) isGatewayRunning() bool { - return isGatewayProcessRunning() -} - -func (s *appState) validateAgentModel() error { - modelName := strings.TrimSpace(s.config.Agents.Defaults.Model) - if modelName == "" { - return nil - } - _, err := s.config.GetModelConfig(modelName) - return err -} - -func (s *appState) isActiveModelValid() bool { - modelName := strings.TrimSpace(s.config.Agents.Defaults.Model) - if modelName == "" { - return false - } - cfg, err := s.config.GetModelConfig(modelName) - if err != nil { - return false - } - hasKey := strings.TrimSpace(cfg.APIKey) != "" || strings.TrimSpace(cfg.AuthMethod) == "oauth" - hasModel := strings.TrimSpace(cfg.Model) != "" - return hasKey && hasModel -} - -func (s *appState) hasEnabledChannel() bool { - c := s.config.Channels - return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled || - c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled || - c.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled -} - -func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) { - if s.pages.HasPage("apply") { - return - } - modal := tview.NewModal(). - SetText("Apply changes or discard before continuing?"). - AddButtons([]string{"Cancel", "Discard", "Apply"}). - SetDoneFunc(func(buttonIndex int, buttonLabel string) { - s.pages.RemovePage("apply") - switch buttonLabel { - case "Discard": - s.discardChanges() - if onDiscard != nil { - onDiscard() - } - case "Apply": - if s.applyChangesValidated() { - s.dirty = false - if onApply != nil { - onApply() - } - } - } - }) - modal.SetBorder(true) - s.pages.AddPage("apply", modal, true, true) -} - -func (s *appState) discardChanges() { - if s.hasOriginal { - _ = writeOriginalConfig(s.configPath, s.original) - } else { - _ = os.Remove(s.configPath) - } - _ = os.Remove(s.backupPath) - if cfg, err := configstore.Load(); err == nil && cfg != nil { - s.config = cfg - } - s.dirty = false - refreshMainMenuIfPresent(s) -} - -func (s *appState) showMessage(title, message string) { - if s.pages.HasPage("message") { - return - } - modal := tview.NewModal(). - SetText(strings.TrimSpace(message)). - AddButtons([]string{"OK"}). - SetDoneFunc(func(_ int, _ string) { - s.pages.RemovePage("message") - }) - modal.SetTitle(title).SetBorder(true) - modal.SetBackgroundColor(tview.Styles.ContrastBackgroundColor) - modal.SetTextColor(tview.Styles.PrimaryTextColor) - modal.SetButtonBackgroundColor(tcell.NewRGBColor(112, 102, 255)) - modal.SetButtonTextColor(tview.Styles.PrimaryTextColor) - s.pages.AddPage("message", modal, true, true) -} - -func loadOriginalConfig(path string) ([]byte, bool) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil, false - } - return nil, false - } - return data, true -} - -func writeOriginalConfig(path string, data []byte) error { - return os.WriteFile(path, data, 0o600) -} - -func writeBackupConfig(path string, data []byte) error { - return os.WriteFile(path, data, 0o600) -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/channel.go b/cmd/picoclaw-launcher-tui/internal/ui/channel.go deleted file mode 100644 index 2f28af123..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/channel.go +++ /dev/null @@ -1,433 +0,0 @@ -package ui - -import ( - "fmt" - "strings" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - picoclawconfig "github.com/sipeed/picoclaw/pkg/config" -) - -func (s *appState) buildChannelMenuItems() []MenuItem { - return []MenuItem{ - channelItem( - "Telegram", - "Telegram bot settings", - s.config.Channels.Telegram.Enabled, - func() { s.push("channel-telegram", s.telegramForm()) }, - ), - channelItem( - "Discord", - "Discord bot settings", - s.config.Channels.Discord.Enabled, - func() { s.push("channel-discord", s.discordForm()) }, - ), - channelItem( - "QQ", - "QQ bot settings", - s.config.Channels.QQ.Enabled, - func() { s.push("channel-qq", s.qqForm()) }, - ), - channelItem( - "MaixCam", - "MaixCam gateway", - s.config.Channels.MaixCam.Enabled, - func() { s.push("channel-maixcam", s.maixcamForm()) }, - ), - channelItem( - "WhatsApp", - "WhatsApp bridge", - s.config.Channels.WhatsApp.Enabled, - func() { s.push("channel-whatsapp", s.whatsappForm()) }, - ), - channelItem( - "Feishu", - "Feishu bot settings", - s.config.Channels.Feishu.Enabled, - func() { s.push("channel-feishu", s.feishuForm()) }, - ), - channelItem( - "DingTalk", - "DingTalk bot settings", - s.config.Channels.DingTalk.Enabled, - func() { s.push("channel-dingtalk", s.dingtalkForm()) }, - ), - channelItem( - "Slack", - "Slack bot settings", - s.config.Channels.Slack.Enabled, - func() { s.push("channel-slack", s.slackForm()) }, - ), - channelItem( - "Matrix", - "Matrix bot settings", - s.config.Channels.Matrix.Enabled, - func() { s.push("channel-matrix", s.matrixForm()) }, - ), - channelItem( - "LINE", - "LINE bot settings", - s.config.Channels.LINE.Enabled, - func() { s.push("channel-line", s.lineForm()) }, - ), - channelItem( - "OneBot", - "OneBot settings", - s.config.Channels.OneBot.Enabled, - func() { s.push("channel-onebot", s.onebotForm()) }, - ), - channelItem( - "WeCom", - "WeCom bot settings", - s.config.Channels.WeCom.Enabled, - func() { s.push("channel-wecom", s.wecomForm()) }, - ), - channelItem( - "WeCom App", - "WeCom App settings", - s.config.Channels.WeComApp.Enabled, - func() { s.push("channel-wecomapp", s.wecomAppForm()) }, - ), - } -} - -func (s *appState) channelMenu() tview.Primitive { - menu := NewMenu("Channels", s.buildChannelMenuItems()) - menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - s.pop() - return nil - } - return event - }) - return menu -} - -func refreshChannelMenuFromState(menu *Menu, s *appState) { - menu.applyItems(s.buildChannelMenuItems()) -} - -func (s *appState) telegramForm() tview.Primitive { - cfg := &s.config.Channels.Telegram - form := baseChannelForm("Telegram", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { - cfg.Token = strings.TrimSpace(text) - }) - form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) { - cfg.Proxy = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) discordForm() tview.Primitive { - cfg := &s.config.Channels.Discord - form := baseChannelForm("Discord", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { - cfg.Token = strings.TrimSpace(text) - }) - form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) { - cfg.MentionOnly = checked - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) qqForm() tview.Primitive { - cfg := &s.config.Channels.QQ - form := baseChannelForm("QQ", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) { - cfg.AppID = strings.TrimSpace(text) - }) - form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) { - cfg.AppSecret = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) maixcamForm() tview.Primitive { - cfg := &s.config.Channels.MaixCam - form := baseChannelForm("MaixCam", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Host", cfg.Host, 64, nil, func(text string) { - cfg.Host = strings.TrimSpace(text) - }) - addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) whatsappForm() tview.Primitive { - cfg := &s.config.Channels.WhatsApp - form := baseChannelForm("WhatsApp", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) { - cfg.BridgeURL = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) feishuForm() tview.Primitive { - cfg := &s.config.Channels.Feishu - form := baseChannelForm("Feishu", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) { - cfg.AppID = strings.TrimSpace(text) - }) - form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) { - cfg.AppSecret = strings.TrimSpace(text) - }) - form.AddInputField("Encrypt Key", cfg.EncryptKey, 128, nil, func(text string) { - cfg.EncryptKey = strings.TrimSpace(text) - }) - form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) { - cfg.VerificationToken = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) dingtalkForm() tview.Primitive { - cfg := &s.config.Channels.DingTalk - form := baseChannelForm("DingTalk", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) { - cfg.ClientID = strings.TrimSpace(text) - }) - form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) { - cfg.ClientSecret = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) slackForm() tview.Primitive { - cfg := &s.config.Channels.Slack - form := baseChannelForm("Slack", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) { - cfg.BotToken = strings.TrimSpace(text) - }) - form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) { - cfg.AppToken = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) lineForm() tview.Primitive { - cfg := &s.config.Channels.LINE - form := baseChannelForm("LINE", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) { - cfg.ChannelSecret = strings.TrimSpace(text) - }) - form.AddInputField("Channel Access Token", cfg.ChannelAccessToken, 128, nil, func(text string) { - cfg.ChannelAccessToken = strings.TrimSpace(text) - }) - form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) { - cfg.WebhookHost = strings.TrimSpace(text) - }) - addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value }) - form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { - cfg.WebhookPath = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) matrixForm() tview.Primitive { - cfg := &s.config.Channels.Matrix - form := baseChannelForm("Matrix", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Homeserver", cfg.Homeserver, 128, nil, func(text string) { - cfg.Homeserver = strings.TrimSpace(text) - }) - form.AddInputField("User ID", cfg.UserID, 128, nil, func(text string) { - cfg.UserID = strings.TrimSpace(text) - }) - form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) { - cfg.AccessToken = strings.TrimSpace(text) - }) - form.AddInputField("Device ID", cfg.DeviceID, 128, nil, func(text string) { - cfg.DeviceID = strings.TrimSpace(text) - }) - form.AddCheckbox("Join On Invite", cfg.JoinOnInvite, func(checked bool) { - cfg.JoinOnInvite = checked - }) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) onebotForm() tview.Primitive { - cfg := &s.config.Channels.OneBot - form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) { - cfg.WSUrl = strings.TrimSpace(text) - }) - form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) { - cfg.AccessToken = strings.TrimSpace(text) - }) - addIntField( - form, - "Reconnect Interval", - cfg.ReconnectInterval, - func(value int) { cfg.ReconnectInterval = value }, - ) - form.AddInputField( - "Group Trigger Prefix", - strings.Join(cfg.GroupTriggerPrefix, ","), - 128, - nil, - func(text string) { - cfg.GroupTriggerPrefix = splitCSV(text) - }, - ) - addAllowFromField(form, &cfg.AllowFrom) - return wrapWithBack(form, s) -} - -func (s *appState) wecomForm() tview.Primitive { - cfg := &s.config.Channels.WeCom - form := baseChannelForm("WeCom", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { - cfg.Token = strings.TrimSpace(text) - }) - form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) { - cfg.EncodingAESKey = strings.TrimSpace(text) - }) - form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) { - cfg.WebhookURL = strings.TrimSpace(text) - }) - form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) { - cfg.WebhookHost = strings.TrimSpace(text) - }) - addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value }) - form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { - cfg.WebhookPath = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - addIntField( - form, - "Reply Timeout", - cfg.ReplyTimeout, - func(value int) { cfg.ReplyTimeout = value }, - ) - return wrapWithBack(form, s) -} - -func (s *appState) wecomAppForm() tview.Primitive { - cfg := &s.config.Channels.WeComApp - form := baseChannelForm("WeCom App", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) - form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) { - cfg.CorpID = strings.TrimSpace(text) - }) - form.AddInputField("Corp Secret", cfg.CorpSecret, 128, nil, func(text string) { - cfg.CorpSecret = strings.TrimSpace(text) - }) - addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value }) - form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { - cfg.Token = strings.TrimSpace(text) - }) - form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) { - cfg.EncodingAESKey = strings.TrimSpace(text) - }) - form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) { - cfg.WebhookHost = strings.TrimSpace(text) - }) - addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value }) - form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { - cfg.WebhookPath = strings.TrimSpace(text) - }) - addAllowFromField(form, &cfg.AllowFrom) - addIntField( - form, - "Reply Timeout", - cfg.ReplyTimeout, - func(value int) { cfg.ReplyTimeout = value }, - ) - return wrapWithBack(form, s) -} - -func (s *appState) makeChannelOnEnabled(enabledPtr *bool) func(bool) { - return func(v bool) { - *enabledPtr = v - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["channel"]; ok { - refreshChannelMenuFromState(menu, s) - } - } -} - -func addAllowFromField(form *tview.Form, allowFrom *picoclawconfig.FlexibleStringSlice) { - form.AddInputField("Allow From", strings.Join(*allowFrom, ","), 128, nil, func(text string) { - *allowFrom = splitCSV(text) - }) -} - -func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form { - form := tview.NewForm() - form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title)) - form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123)) - form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22)) - form.AddCheckbox("Enabled", enabled, func(checked bool) { - onEnabled(checked) - }) - return form -} - -func wrapWithBack(form *tview.Form, s *appState) tview.Primitive { - form.AddButton("Back", func() { - s.pop() - }) - form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - s.pop() - return nil - } - return event - }) - return form -} - -func splitCSV(input string) picoclawconfig.FlexibleStringSlice { - parts := strings.Split(strings.TrimSpace(input), ",") - cleaned := make([]string, 0, len(parts)) - for _, part := range parts { - value := strings.TrimSpace(part) - if value == "" { - continue - } - cleaned = append(cleaned, value) - } - return cleaned -} - -func addIntField(form *tview.Form, label string, value int, onChange func(int)) { - form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) { - var parsed int - if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil { - onChange(parsed) - } - }) -} - -func addInt64Field(form *tview.Form, label string, value int64, onChange func(int64)) { - form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) { - var parsed int64 - if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil { - onChange(parsed) - } - }) -} - -func channelItem(label, description string, enabled bool, action MenuAction) MenuItem { - item := MenuItem{ - Label: label, - Description: description, - Action: action, - } - if !enabled { - color := tcell.ColorGray - item.MainColor = &color - } - return item -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go b/cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go deleted file mode 100644 index bc874f7f2..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build !windows -// +build !windows - -package ui - -import "os/exec" - -func isGatewayProcessRunning() bool { - cmd := exec.Command("sh", "-c", "pgrep -f 'picoclaw\\s+gateway' >/dev/null 2>&1") - return cmd.Run() == nil -} - -func stopGatewayProcess() error { - cmd := exec.Command("sh", "-c", "pkill -f 'picoclaw\\s+gateway' >/dev/null 2>&1") - return cmd.Run() -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go b/cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go deleted file mode 100644 index 7067a5c13..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build windows -// +build windows - -package ui - -import "os/exec" - -func isGatewayProcessRunning() bool { - cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq picoclaw.exe") - return cmd.Run() == nil -} - -func stopGatewayProcess() error { - cmd := exec.Command("taskkill", "/F", "/IM", "picoclaw.exe") - return cmd.Run() -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/menu.go b/cmd/picoclaw-launcher-tui/internal/ui/menu.go deleted file mode 100644 index 9f2132c5a..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/menu.go +++ /dev/null @@ -1,72 +0,0 @@ -package ui - -import ( - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -type MenuAction func() - -type MenuItem struct { - Label string - Description string - Action MenuAction - Disabled bool - MainColor *tcell.Color - DescColor *tcell.Color -} - -type Menu struct { - *tview.Table - items []MenuItem -} - -func NewMenu(title string, items []MenuItem) *Menu { - table := tview.NewTable().SetSelectable(true, false) - table.SetBorder(true).SetTitle(title) - table.SetBorders(false) - menu := &Menu{Table: table, items: items} - menu.applyItems(items) - menu.SetSelectedFunc(func(row, _ int) { - if row < 0 || row >= len(menu.items) { - return - } - item := menu.items[row] - if item.Disabled || item.Action == nil { - return - } - item.Action() - }) - menu.SetSelectedStyle( - tcell.StyleDefault.Foreground(tview.Styles.InverseTextColor). - Background(tcell.NewRGBColor(189, 147, 249)), - ) - return menu -} - -func (m *Menu) applyItems(items []MenuItem) { - m.items = items - m.Clear() - for row, item := range items { - label := item.Label - if item.Disabled && label != "" { - label = label + " (disabled)" - } - left := tview.NewTableCell(label) - right := tview.NewTableCell(item.Description).SetAlign(tview.AlignRight) - if item.MainColor != nil { - left.SetTextColor(*item.MainColor) - } - if item.DescColor != nil { - right.SetTextColor(*item.DescColor) - } else { - right.SetTextColor(tview.Styles.TertiaryTextColor) - } - if item.Disabled { - left.SetTextColor(tcell.ColorGray) - right.SetTextColor(tcell.ColorGray) - } - m.SetCell(row, 0, left) - m.SetCell(row, 1, right) - } -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/model.go b/cmd/picoclaw-launcher-tui/internal/ui/model.go deleted file mode 100644 index 698502058..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/model.go +++ /dev/null @@ -1,399 +0,0 @@ -package ui - -import ( - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - picoclawconfig "github.com/sipeed/picoclaw/pkg/config" -) - -func (s *appState) modelMenu() tview.Primitive { - items := make([]MenuItem, 0, 1+len(s.config.ModelList)) - currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model) - for i := range s.config.ModelList { - index := i - model := s.config.ModelList[i] - isValid := isModelValid(model) - desc := model.APIBase - if desc == "" { - desc = model.AuthMethod - } - if desc == "" { - desc = "api_key required" - } - label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model) - if model.ModelName == currentModel && currentModel != "" { - label = "* " + label - } - isSelected := model.ModelName == currentModel && currentModel != "" - items = append(items, MenuItem{ - Label: label, - Description: desc, - MainColor: modelStatusColor(isValid, isSelected), - Action: func() { - s.push(fmt.Sprintf("model-%d", index), s.modelForm(index)) - }, - }) - } - // Add model entry appended at the end so the models map to rows 1..N - items = append(items, - MenuItem{ - Label: "**Add model**", - Description: "Append a new model entry", - Action: func() { - newName := s.nextAvailableModelName("new-model") - s.addModel( - picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"}, - ) - s.push( - fmt.Sprintf("model-%d", len(s.config.ModelList)-1), - s.modelForm(len(s.config.ModelList)-1), - ) - }, - }, - ) - - menu := NewMenu("Models", items) - menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - s.pop() - return nil - } - - if event.Rune() == ' ' { - row, _ := menu.GetSelection() - if row >= 0 && row < len(s.config.ModelList) { - model := s.config.ModelList[row] - if !isModelValid(model) { - s.showMessage( - "Invalid model", - "Select a model with api_key or oauth auth_method", - ) - return nil - } - s.config.Agents.Defaults.Model = model.ModelName - s.dirty = true - refreshModelMenu(menu, s.config.Agents.Defaults.Model, s.config.ModelList) - refreshMainMenuIfPresent(s) - } - return nil - } - return event - }) - return menu -} - -func (s *appState) modelForm(index int) tview.Primitive { - model := &s.config.ModelList[index] - form := tview.NewForm() - form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName)) - - addInput(form, "Model Name", model.ModelName, func(value string) { - if value == "" { - s.showMessage("Invalid model name", "Model Name cannot be empty") - return - } - if s.modelNameExists(value, index) { - s.showMessage("Duplicate model name", fmt.Sprintf("Model Name '%s' already exists", value)) - return - } - oldName := model.ModelName - model.ModelName = value - if s.config.Agents.Defaults.Model == oldName { - s.config.Agents.Defaults.Model = value - } - s.dirty = true - form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName)) - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["model"]; ok { - refreshModelMenuFromState(menu, s) - } - }) - addInput(form, "Model", model.Model, func(value string) { - model.Model = value - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["model"]; ok { - refreshModelMenuFromState(menu, s) - } - }) - addInput(form, "API Base", model.APIBase, func(value string) { - model.APIBase = value - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["model"]; ok { - refreshModelMenuFromState(menu, s) - } - }) - addInput(form, "API Key", model.APIKey, func(value string) { - model.APIKey = value - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["model"]; ok { - refreshModelMenuFromState(menu, s) - } - }) - addInput(form, "Proxy", model.Proxy, func(value string) { - model.Proxy = value - }) - addInput(form, "Auth Method", model.AuthMethod, func(value string) { - model.AuthMethod = value - s.dirty = true - refreshMainMenuIfPresent(s) - if menu, ok := s.menus["model"]; ok { - refreshModelMenuFromState(menu, s) - } - }) - addInput(form, "Connect Mode", model.ConnectMode, func(value string) { - model.ConnectMode = value - }) - addInput(form, "Workspace", model.Workspace, func(value string) { - model.Workspace = value - }) - addInput(form, "Max Tokens Field", model.MaxTokensField, func(value string) { - model.MaxTokensField = value - }) - addIntInput(form, "RPM", model.RPM, func(value int) { - model.RPM = value - }) - addIntInput(form, "Request Timeout", model.RequestTimeout, func(value int) { - model.RequestTimeout = value - }) - - form.AddButton("Delete", func() { - pageName := "confirm-delete-model" - if s.pages.HasPage(pageName) { - return - } - modal := tview.NewModal(). - SetText("Are you sure you want to delete this model?"). - AddButtons([]string{"Cancel", "Delete"}). - SetDoneFunc(func(buttonIndex int, buttonLabel string) { - s.pages.RemovePage(pageName) - if buttonLabel == "Delete" { - s.deleteModel(index) - } - }) - modal.SetTitle("Confirm Delete").SetBorder(true) - s.pages.AddPage(pageName, modal, true, true) - }) - form.AddButton("Test", func() { - s.testModel(model) - }) - form.AddButton("Back", func() { - s.pop() - }) - - form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - s.pop() - return nil - } - return event - }) - return form -} - -func addInput(form *tview.Form, label, value string, onChange func(string)) { - form.AddInputField(label, value, 128, nil, func(text string) { - onChange(strings.TrimSpace(text)) - }) -} - -func addIntInput(form *tview.Form, label string, value int, onChange func(int)) { - form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) { - var parsed int - if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil { - onChange(parsed) - } - }) -} - -func (s *appState) addModel(model picoclawconfig.ModelConfig) { - s.config.ModelList = append(s.config.ModelList, model) -} - -func (s *appState) deleteModel(index int) { - if index < 0 || index >= len(s.config.ModelList) { - return - } - s.config.ModelList = append(s.config.ModelList[:index], s.config.ModelList[index+1:]...) - s.pop() -} - -func modelStatusColor(valid bool, selected bool) *tcell.Color { - if valid { - color := tview.Styles.PrimaryTextColor - return &color - } - color := tcell.ColorGray - return &color -} - -func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) { - for i, model := range models { - row := i - label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model) - isValid := isModelValid(model) - if model.ModelName == currentModel && currentModel != "" { - label = "* " + label - } - cell := menu.GetCell(row, 0) - if cell != nil { - cell.SetText(label) - isSelected := model.ModelName == currentModel && currentModel != "" - color := modelStatusColor(isValid, isSelected) - if color != nil { - cell.SetTextColor(*color) - } - } - } -} - -func refreshModelMenuFromState(menu *Menu, s *appState) { - items := make([]MenuItem, 0, 1+len(s.config.ModelList)) - currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model) - for i := range s.config.ModelList { - index := i - model := s.config.ModelList[i] - isValid := isModelValid(model) - desc := model.APIBase - if desc == "" { - desc = model.AuthMethod - } - if desc == "" { - desc = "api_key required" - } - label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model) - if model.ModelName == currentModel && currentModel != "" { - label = "* " + label - } - isSelected := model.ModelName == currentModel && currentModel != "" - items = append(items, MenuItem{ - Label: label, - Description: desc, - MainColor: modelStatusColor(isValid, isSelected), - Action: func() { - s.push(fmt.Sprintf("model-%d", index), s.modelForm(index)) - }, - }) - } - items = append(items, - MenuItem{ - Label: "**Add Model**", - Description: "Append a new model entry", - Action: func() { - newName := s.nextAvailableModelName("new-model") - s.addModel( - picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"}, - ) - s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1)) - }, - }, - ) - menu.applyItems(items) -} - -func isModelValid(model picoclawconfig.ModelConfig) bool { - hasKey := strings.TrimSpace(model.APIKey) != "" || - strings.TrimSpace(model.AuthMethod) == "oauth" - hasModel := strings.TrimSpace(model.Model) != "" - return hasKey && hasModel -} - -func (s *appState) modelNameExists(name string, excludeIndex int) bool { - target := strings.TrimSpace(name) - if target == "" { - return false - } - for i := range s.config.ModelList { - if i == excludeIndex { - continue - } - if strings.TrimSpace(s.config.ModelList[i].ModelName) == target { - return true - } - } - return false -} - -func (s *appState) nextAvailableModelName(base string) string { - name := strings.TrimSpace(base) - if name == "" { - name = "new-model" - } - if !s.modelNameExists(name, -1) { - return name - } - for i := 2; ; i++ { - candidate := fmt.Sprintf("%s-%d", name, i) - if !s.modelNameExists(candidate, -1) { - return candidate - } - } -} - -func (s *appState) testModel(model *picoclawconfig.ModelConfig) { - if model == nil { - return - } - if strings.TrimSpace(model.APIKey) == "" { - s.showMessage("Missing API Key", "Set api_key before testing") - return - } - base := strings.TrimSpace(model.APIBase) - if base == "" { - s.showMessage("Missing API Base", "Set api_base before testing") - return - } - modelID := strings.TrimSpace(model.Model) - if modelID == "" { - s.showMessage("Missing Model", "Set model before testing") - return - } - if !strings.HasPrefix(modelID, "openai/") { - s.showMessage("Unsupported model", "Only openai/* models are supported for test") - return - } - modelName := strings.TrimPrefix(modelID, "openai/") - endpoint := strings.TrimRight(base, "/") + "/chat/completions" - - payload := fmt.Sprintf( - `{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}`, - modelName, - ) - client := &http.Client{Timeout: 10 * time.Second} - request, err := http.NewRequest("POST", endpoint, strings.NewReader(payload)) - if err != nil { - s.showMessage("Test failed", err.Error()) - return - } - request.Header.Set("Content-Type", "application/json") - request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey)) - - resp, err := client.Do(request) - if err != nil { - s.showMessage("Test failed", err.Error()) - return - } - defer resp.Body.Close() - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - s.showMessage("Test OK", resp.Status) - return - } - body, err := io.ReadAll(io.LimitReader(resp.Body, 2048)) - if err != nil { - s.showMessage("Test failed", fmt.Sprintf("failed to read response: %v", err)) - return - } - s.showMessage( - "Test failed", - fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))), - ) -} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/style.go b/cmd/picoclaw-launcher-tui/internal/ui/style.go deleted file mode 100644 index da3c3526d..000000000 --- a/cmd/picoclaw-launcher-tui/internal/ui/style.go +++ /dev/null @@ -1,55 +0,0 @@ -package ui - -import ( - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -const ( - colorBlue = "[#3e5db9]" - colorRed = "[#d54646]" - banner = "\r\n[::b]" + - colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" + - colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" + - colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" + - colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" + - colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" + - colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " + - "[:]" -) - -func applyStyles() { - tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22) - tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53) - tview.Styles.MoreContrastBackgroundColor = tcell.NewRGBColor(18, 18, 32) - tview.Styles.BorderColor = tcell.NewRGBColor(112, 102, 255) - tview.Styles.TitleColor = tcell.NewRGBColor(255, 121, 198) - tview.Styles.GraphicsColor = tcell.NewRGBColor(139, 233, 253) - tview.Styles.PrimaryTextColor = tcell.NewRGBColor(241, 250, 255) - tview.Styles.SecondaryTextColor = tcell.NewRGBColor(80, 250, 123) - tview.Styles.TertiaryTextColor = tcell.NewRGBColor(139, 233, 253) - tview.Styles.InverseTextColor = tcell.NewRGBColor(12, 13, 22) - tview.Styles.ContrastSecondaryTextColor = tcell.NewRGBColor(189, 147, 249) -} - -func bannerView() *tview.TextView { - text := tview.NewTextView() - text.SetDynamicColors(true) - text.SetTextAlign(tview.AlignCenter) - text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor) - text.SetText(banner) - text.SetBorder(false) - return text -} - -const footerText = "Esc: Back/Exit | Enter: Enter | ←↓↑→ : Move | Space: Select | Tab/Shift+Tab: Switch" - -func footerView() *tview.TextView { - text := tview.NewTextView() - text.SetTextAlign(tview.AlignCenter) - text.SetText(footerText) - text.SetBackgroundColor(tview.Styles.MoreContrastBackgroundColor) - text.SetTextColor(tview.Styles.PrimaryTextColor) - text.SetBorder(false) - return text -} diff --git a/cmd/picoclaw-launcher-tui/main.go b/cmd/picoclaw-launcher-tui/main.go deleted file mode 100644 index 0e8cce415..000000000 --- a/cmd/picoclaw-launcher-tui/main.go +++ /dev/null @@ -1,15 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui" -) - -func main() { - if err := ui.Run(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} From 5a199ec9937bcc1d0e5172b1b9f4869de328a5ce Mon Sep 17 00:00:00 2001 From: taorye Date: Fri, 20 Mar 2026 11:54:58 +0800 Subject: [PATCH 02/10] feat: implement TUI configuration and user management for picoclaw-launcher-tui --- cmd/picoclaw-launcher-tui/config/config.go | 159 ++++++++++++++++++++ cmd/picoclaw-launcher-tui/main.go | 33 +++++ cmd/picoclaw-launcher-tui/ui/app.go | 123 ++++++++++++++++ cmd/picoclaw-launcher-tui/ui/home.go | 43 ++++++ cmd/picoclaw-launcher-tui/ui/models.go | 143 ++++++++++++++++++ cmd/picoclaw-launcher-tui/ui/schemes.go | 147 +++++++++++++++++++ cmd/picoclaw-launcher-tui/ui/users.go | 161 +++++++++++++++++++++ go.mod | 1 + go.sum | 2 + 9 files changed, 812 insertions(+) create mode 100644 cmd/picoclaw-launcher-tui/config/config.go create mode 100644 cmd/picoclaw-launcher-tui/main.go create mode 100644 cmd/picoclaw-launcher-tui/ui/app.go create mode 100644 cmd/picoclaw-launcher-tui/ui/home.go create mode 100644 cmd/picoclaw-launcher-tui/ui/models.go create mode 100644 cmd/picoclaw-launcher-tui/ui/schemes.go create mode 100644 cmd/picoclaw-launcher-tui/ui/users.go diff --git a/cmd/picoclaw-launcher-tui/config/config.go b/cmd/picoclaw-launcher-tui/config/config.go new file mode 100644 index 000000000..15c81f90a --- /dev/null +++ b/cmd/picoclaw-launcher-tui/config/config.go @@ -0,0 +1,159 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +// Package config provides types and I/O for ~/.picoclaw/tui.toml. +package config + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" + "github.com/sipeed/picoclaw/pkg/fileutil" +) + +// DefaultConfigPath returns the default path to the tui.toml config file. +func DefaultConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + return filepath.Join(home, ".picoclaw", "tui.toml") +} + +// TUIConfig is the top-level structure of ~/.picoclaw/tui.toml. +type TUIConfig struct { + Version string `toml:"version"` + Model Model `toml:"model"` + Provider Provider `toml:"provider"` +} + +type Model struct { + Type string `toml:"type"` // "provider" (default) | "manual" +} + +type Provider struct { + Schemes []Scheme `toml:"schemes"` + Users []User `toml:"users"` + Current ProviderCurrent `toml:"current"` +} + +type Scheme struct { + Name string `toml:"name"` // unique key + BaseURL string `toml:"baseURL"` // required + Type string `toml:"type"` // "openai-compatible" (default) | "anthropic" +} + +type User struct { + Name string `toml:"name"` + Scheme string `toml:"scheme"` // references Scheme.Name; (Name+Scheme) is unique + Type string `toml:"type"` // "key" (default) | "OAuth" + Key string `toml:"key"` +} + +type ProviderCurrent struct { + Scheme string `toml:"scheme"` // references Scheme.Name + User string `toml:"user"` // references User.Name where User.Scheme == Scheme + Model string `toml:"model"` // from GET /models +} + +// DefaultConfig returns a minimal valid TUIConfig. +func DefaultConfig() *TUIConfig { + return &TUIConfig{ + Version: "1.0", + Model: Model{Type: "provider"}, + Provider: Provider{ + Schemes: []Scheme{}, + Users: []User{}, + Current: ProviderCurrent{}, + }, + } +} + +// Load reads the TUI config from path. Returns a default config if the file does not exist. +func Load(path string) (*TUIConfig, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return DefaultConfig(), nil + } + if err != nil { + return nil, fmt.Errorf("failed to read config file %q: %w", path, err) + } + + cfg := DefaultConfig() + if _, err := toml.Decode(string(data), cfg); err != nil { + return nil, fmt.Errorf("failed to parse config file %q: %w", path, err) + } + + applyDefaults(cfg) + return cfg, nil +} + +// Save writes cfg to path atomically (safe for flash / SD storage). +func Save(path string, cfg *TUIConfig) error { + var buf bytes.Buffer + enc := toml.NewEncoder(&buf) + if err := enc.Encode(cfg); err != nil { + return fmt.Errorf("failed to encode config: %w", err) + } + if err := fileutil.WriteFileAtomic(path, buf.Bytes(), 0o600); err != nil { + return fmt.Errorf("failed to write config file %q: %w", path, err) + } + return nil +} + +func applyDefaults(cfg *TUIConfig) { + if cfg.Version == "" { + cfg.Version = "1.0" + } + if cfg.Model.Type == "" { + cfg.Model.Type = "provider" + } + for i := range cfg.Provider.Schemes { + if cfg.Provider.Schemes[i].Type == "" { + cfg.Provider.Schemes[i].Type = "openai-compatible" + } + } + for i := range cfg.Provider.Users { + if cfg.Provider.Users[i].Type == "" { + cfg.Provider.Users[i].Type = "key" + } + } +} + +// SchemeByName returns the first Scheme whose Name matches, or nil. +func (p *Provider) SchemeByName(name string) *Scheme { + for i := range p.Schemes { + if p.Schemes[i].Name == name { + return &p.Schemes[i] + } + } + return nil +} + +// UsersForScheme returns all users whose Scheme field matches schemeName. +func (p *Provider) UsersForScheme(schemeName string) []User { + var out []User + for _, u := range p.Users { + if u.Scheme == schemeName { + out = append(out, u) + } + } + return out +} + +func (cfg *TUIConfig) CurrentModelLabel() string { + cur := cfg.Provider.Current + if cur.Model == "" { + return "(not configured)" + } + label := cur.Scheme + if label != "" { + label += " / " + } + return label + cur.Model +} diff --git a/cmd/picoclaw-launcher-tui/main.go b/cmd/picoclaw-launcher-tui/main.go new file mode 100644 index 000000000..3d7e62b08 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/main.go @@ -0,0 +1,33 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package main + +import ( + "fmt" + "os" + + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" + "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui" +) + +func main() { + configPath := tuicfg.DefaultConfigPath() + if len(os.Args) > 1 { + configPath = os.Args[1] + } + + cfg, err := tuicfg.Load(configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err) + os.Exit(1) + } + + app := ui.New(cfg, configPath) + if err := app.Run(); err != nil { + fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go new file mode 100644 index 000000000..c642a1753 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/app.go @@ -0,0 +1,123 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" +) + +// App is the root TUI application. +type App struct { + tapp *tview.Application + pages *tview.Pages + pageStack []string + cfg *tuicfg.TUIConfig + configPath string + homeRefreshFn func() +} + +// New creates and wires up the TUI application. +func New(cfg *tuicfg.TUIConfig, configPath string) *App { + a := &App{ + tapp: tview.NewApplication(), + pages: tview.NewPages(), + pageStack: []string{}, + cfg: cfg, + configPath: configPath, + } + + a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + return a.goBack() + } + return event + }) + + a.buildPages() + return a +} + +// Run starts the TUI event loop. +func (a *App) Run() error { + return a.tapp.SetRoot(a.pages, true).EnableMouse(true).Run() +} + +func (a *App) buildPages() { + a.pages.AddPage("home", a.newHomePage(), true, true) + a.pageStack = []string{"home"} +} + +func (a *App) navigateTo(name string, page tview.Primitive) { + a.pages.AddPage(name, page, true, false) + a.pageStack = append(a.pageStack, name) + a.pages.SwitchToPage(name) +} + +func (a *App) goBack() *tcell.EventKey { + if len(a.pageStack) <= 1 { + return nil + } + a.pageStack = a.pageStack[:len(a.pageStack)-1] + prev := a.pageStack[len(a.pageStack)-1] + if prev == "home" && a.homeRefreshFn != nil { + a.homeRefreshFn() + } + a.pages.SwitchToPage(prev) + return nil +} + +func (a *App) showModal(name string, primitive tview.Primitive) { + a.pages.AddPage(name, primitive, true, true) +} + +func (a *App) hideModal(name string) { + a.pages.HidePage(name) + a.pages.RemovePage(name) +} + +func (a *App) save() { + _ = tuicfg.Save(a.configPath, a.cfg) +} + +func (a *App) showError(msg string) { + modal := tview.NewModal(). + SetText("Error: " + msg). + AddButtons([]string{"OK"}). + SetDoneFunc(func(_ int, _ string) { + a.hideModal("error") + }) + a.showModal("error", modal) +} + +func (a *App) confirmDelete(label string, onConfirm func()) { + modal := tview.NewModal(). + SetText("Delete " + label + "?\nThis cannot be undone."). + AddButtons([]string{"Delete", "Cancel"}). + SetDoneFunc(func(_ int, buttonLabel string) { + a.hideModal("confirm-delete") + if buttonLabel == "Delete" { + onConfirm() + } + }) + a.showModal("confirm-delete", modal) +} + +func centeredForm(form *tview.Form, width, height int) tview.Primitive { + return tview.NewGrid(). + SetColumns(0, width, 0). + SetRows(0, height, 0). + AddItem(form, 1, 1, 1, 1, 0, 0, true) +} + +func hintBar(text string) *tview.TextView { + tv := tview.NewTextView(). + SetText(text). + SetTextAlign(tview.AlignCenter) + tv.SetBackgroundColor(tcell.ColorDarkBlue) + return tv +} diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go new file mode 100644 index 000000000..6235a2c8e --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/home.go @@ -0,0 +1,43 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func (a *App) newHomePage() tview.Primitive { + list := tview.NewList() + list.SetBorder(true).SetTitle(" picoclaw-launcher-tui ") + + rebuildList := func() { + sel := list.GetCurrentItem() + list.Clear() + list.AddItem("model: "+a.cfg.CurrentModelLabel(), "Enter to configure", 'm', func() { + a.pages.RemovePage("schemes") + a.navigateTo("schemes", a.newSchemesPage()) + }) + list.AddItem("Quit", "", 'q', func() { a.tapp.Stop() }) + if sel > 0 && sel < list.GetItemCount() { + list.SetCurrentItem(sel) + } + } + rebuildList() + + a.homeRefreshFn = rebuildList + + list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + return event + }) + + footer := hintBar(" Enter: select q: quit ") + + return tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(list, 0, 1, true). + AddItem(footer, 1, 0, false) +} diff --git a/cmd/picoclaw-launcher-tui/ui/models.go b/cmd/picoclaw-launcher-tui/ui/models.go new file mode 100644 index 000000000..5e102d94c --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/models.go @@ -0,0 +1,143 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" +) + +type modelsAPIResponse struct { + Data []modelEntry `json:"data"` +} + +type modelEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive { + table := tview.NewTable(). + SetBorders(false). + SetSelectable(true, false). + SetFixed(0, 0) + table.SetBorder(true).SetTitle(fmt.Sprintf(" Models %s / %s ", schemeName, userName)) + + var modelIDs []string + + status := tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetDynamicColors(true). + SetText("[yellow]Fetching models…[-]") + + footer := hintBar(" Enter: select ESC: back ") + + flex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(status, 1, 0, false). + AddItem(table, 0, 1, false). + AddItem(footer, 1, 0, false) + + apiKey := a.resolveKey(schemeName, userName) + + go func() { + entries, err := fetchModels(baseURL, apiKey) + a.tapp.QueueUpdateDraw(func() { + if err != nil { + status.SetText(fmt.Sprintf("[red]Error: %s[-]", err.Error())) + table.SetCell(0, 0, tview.NewTableCell("(failed to load models)")) + a.tapp.SetFocus(table) + return + } + if len(entries) == 0 { + status.SetText("[yellow]No models returned[-]") + table.SetCell(0, 0, tview.NewTableCell("(no models available)")) + a.tapp.SetFocus(table) + return + } + + status.SetText(fmt.Sprintf("[green]%d model(s) loaded[-]", len(entries))) + for i, m := range entries { + modelIDs = append(modelIDs, m.ID) + table.SetCell(i, 0, + tview.NewTableCell(fmt.Sprintf("%3d", i+1)). + SetAlign(tview.AlignRight). + SetTextColor(tcell.ColorGray). + SetSelectable(false), + ) + table.SetCell(i, 1, + tview.NewTableCell(" "+m.ID). + SetAlign(tview.AlignLeft). + SetExpansion(1), + ) + } + a.tapp.SetFocus(table) + }) + }() + + table.SetSelectedFunc(func(row, _ int) { + if row < 0 || row >= len(modelIDs) { + return + } + a.cfg.Provider.Current = tuicfg.ProviderCurrent{ + Scheme: schemeName, + User: userName, + Model: modelIDs[row], + } + a.save() + a.goBack() + }) + + return flex +} + +func (a *App) resolveKey(schemeName, userName string) string { + for _, u := range a.cfg.Provider.Users { + if u.Scheme == schemeName && u.Name == userName { + return u.Key + } + } + return "" +} + +func fetchModels(baseURL, apiKey string) ([]modelEntry, error) { + url := strings.TrimRight(baseURL, "/") + "/models" + + client := &http.Client{Timeout: 15 * time.Second} + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var result modelsAPIResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return result.Data, nil +} diff --git a/cmd/picoclaw-launcher-tui/ui/schemes.go b/cmd/picoclaw-launcher-tui/ui/schemes.go new file mode 100644 index 000000000..eec3bda7c --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/schemes.go @@ -0,0 +1,147 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" +) + +func (a *App) newSchemesPage() tview.Primitive { + list := tview.NewList() + list.SetBorder(true).SetTitle(" Provider Schemes (a:add e:edit d:delete Enter:users) ") + + rebuild := func() { + sel := list.GetCurrentItem() + list.Clear() + for _, s := range a.cfg.Provider.Schemes { + name := s.Name + list.AddItem( + fmt.Sprintf("%s · %s [%s]", s.Name, s.BaseURL, s.Type), + "", + 0, + func() { + a.pages.RemovePage("users") + a.navigateTo("users", a.newUsersPage(name)) + }, + ) + } + if sel >= 0 && sel < list.GetItemCount() { + list.SetCurrentItem(sel) + } + } + rebuild() + + list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Rune() { + case 'a': + a.showSchemeForm(nil, func(s tuicfg.Scheme) { + a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s) + a.save() + rebuild() + }) + return nil + case 'e': + idx := list.GetCurrentItem() + if idx < 0 || idx >= len(a.cfg.Provider.Schemes) { + return nil + } + orig := a.cfg.Provider.Schemes[idx] + a.showSchemeForm(&orig, func(s tuicfg.Scheme) { + a.cfg.Provider.Schemes[idx] = s + a.save() + rebuild() + }) + return nil + case 'd': + idx := list.GetCurrentItem() + if idx < 0 || idx >= len(a.cfg.Provider.Schemes) { + return nil + } + name := a.cfg.Provider.Schemes[idx].Name + a.confirmDelete(fmt.Sprintf("scheme %q", name), func() { + schemes := a.cfg.Provider.Schemes + a.cfg.Provider.Schemes = append(schemes[:idx], schemes[idx+1:]...) + a.save() + rebuild() + }) + return nil + } + return event + }) + + footer := hintBar(" Enter: users a: add e: edit d: delete ESC: back ") + + return tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(list, 0, 1, true). + AddItem(footer, 1, 0, false) +} + +func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) { + name := "" + baseURL := "" + schemeType := "openai-compatible" + title := " Add Scheme " + + if existing != nil { + name = existing.Name + baseURL = existing.BaseURL + schemeType = existing.Type + title = " Edit Scheme " + } + + typeOptions := []string{"openai-compatible", "anthropic"} + typeIdx := 0 + for i, t := range typeOptions { + if t == schemeType { + typeIdx = i + break + } + } + + form := tview.NewForm() + + var nameField *tview.InputField + + form. + AddInputField("Name", name, 40, nil, func(text string) { name = text }). + AddInputField("Base URL", baseURL, 60, nil, func(text string) { baseURL = text }). + AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }). + AddButton("Save", func() { + _ = nameField + if name == "" { + a.showError("Name is required") + return + } + if baseURL == "" { + a.showError("Base URL is required") + return + } + if existing == nil { + for _, s := range a.cfg.Provider.Schemes { + if s.Name == name { + a.showError(fmt.Sprintf("Scheme name %q already exists", name)) + return + } + } + } + a.hideModal("scheme-form") + onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType}) + }). + AddButton("Cancel", func() { + a.hideModal("scheme-form") + }) + + nameField, _ = form.GetFormItemByLabel("Name").(*tview.InputField) + + form.SetBorder(true).SetTitle(title) + + a.showModal("scheme-form", centeredForm(form, 68, 12)) +} diff --git a/cmd/picoclaw-launcher-tui/ui/users.go b/cmd/picoclaw-launcher-tui/ui/users.go new file mode 100644 index 000000000..27b7cea7a --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/users.go @@ -0,0 +1,161 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" +) + +func (a *App) newUsersPage(schemeName string) tview.Primitive { + list := tview.NewList() + list.SetBorder(true).SetTitle(fmt.Sprintf(" Users for scheme %q (a:add e:edit d:delete Enter:models) ", schemeName)) + + indexInCfg := func(visibleIdx int) int { + count := 0 + for i, u := range a.cfg.Provider.Users { + if u.Scheme == schemeName { + if count == visibleIdx { + return i + } + count++ + } + } + return -1 + } + + rebuild := func() { + sel := list.GetCurrentItem() + list.Clear() + for _, u := range a.cfg.Provider.Users { + if u.Scheme != schemeName { + continue + } + uName := u.Name + uType := u.Type + list.AddItem( + fmt.Sprintf("%s · %s", u.Name, uType), + "", + 0, + func() { + a.pages.RemovePage("models") + scheme := a.cfg.Provider.SchemeByName(schemeName) + if scheme == nil { + a.showError(fmt.Sprintf("Scheme %q not found", schemeName)) + return + } + a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL)) + }, + ) + } + if sel >= 0 && sel < list.GetItemCount() { + list.SetCurrentItem(sel) + } + } + rebuild() + + list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Rune() { + case 'a': + a.showUserForm(schemeName, nil, func(u tuicfg.User) { + a.cfg.Provider.Users = append(a.cfg.Provider.Users, u) + a.save() + rebuild() + }) + return nil + case 'e': + visIdx := list.GetCurrentItem() + cfgIdx := indexInCfg(visIdx) + if cfgIdx < 0 { + return nil + } + orig := a.cfg.Provider.Users[cfgIdx] + a.showUserForm(schemeName, &orig, func(u tuicfg.User) { + a.cfg.Provider.Users[cfgIdx] = u + a.save() + rebuild() + }) + return nil + case 'd': + visIdx := list.GetCurrentItem() + cfgIdx := indexInCfg(visIdx) + if cfgIdx < 0 { + return nil + } + uName := a.cfg.Provider.Users[cfgIdx].Name + a.confirmDelete(fmt.Sprintf("user %q", uName), func() { + users := a.cfg.Provider.Users + a.cfg.Provider.Users = append(users[:cfgIdx], users[cfgIdx+1:]...) + a.save() + rebuild() + }) + return nil + } + return event + }) + + footer := hintBar(" Enter: select model a: add e: edit d: delete ESC: back ") + + return tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(list, 0, 1, true). + AddItem(footer, 1, 0, false) +} + +func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) { + name := "" + userType := "key" + key := "" + title := " Add User " + + if existing != nil { + name = existing.Name + userType = existing.Type + key = existing.Key + title = " Edit User " + } + + typeOptions := []string{"key", "OAuth"} + typeIdx := 0 + for i, t := range typeOptions { + if t == userType { + typeIdx = i + break + } + } + + form := tview.NewForm() + form. + AddInputField("Name", name, 40, nil, func(text string) { name = text }). + AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }). + AddPasswordField("Key", key, 60, '*', func(text string) { key = text }). + AddButton("Save", func() { + if name == "" { + a.showError("Name is required") + return + } + if existing == nil { + for _, u := range a.cfg.Provider.Users { + if u.Scheme == schemeName && u.Name == name { + a.showError(fmt.Sprintf("User name %q already exists for this scheme", name)) + return + } + } + } + a.hideModal("user-form") + onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key}) + }). + AddButton("Cancel", func() { + a.hideModal("user-form") + }) + + form.SetBorder(true).SetTitle(title) + + a.showModal("user-form", centeredForm(form, 68, 13)) +} diff --git a/go.mod b/go.mod index 39385edca..cfc930d37 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/sipeed/picoclaw go 1.25.8 require ( + github.com/BurntSushi/toml v1.6.0 fyne.io/systray v1.12.0 github.com/adhocore/gronx v1.19.6 github.com/anthropics/anthropic-sdk-go v1.26.0 diff --git a/go.sum b/go.sum index 3e6001480..f24b997d4 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= From 119cc2e8e156454fa0cc0658ad9b8e3d112e6be1 Mon Sep 17 00:00:00 2001 From: taorye Date: Fri, 20 Mar 2026 15:39:15 +0800 Subject: [PATCH 03/10] refactor: enhance TUI configuration and user management with improved UI elements and concurrency --- cmd/picoclaw-launcher-tui/config/config.go | 3 + cmd/picoclaw-launcher-tui/ui/app.go | 231 +++++++++++++++++++-- cmd/picoclaw-launcher-tui/ui/home.go | 23 +- cmd/picoclaw-launcher-tui/ui/models.go | 56 +++-- cmd/picoclaw-launcher-tui/ui/schemes.go | 181 ++++++++++++---- cmd/picoclaw-launcher-tui/ui/users.go | 198 +++++++++++++----- 6 files changed, 545 insertions(+), 147 deletions(-) diff --git a/cmd/picoclaw-launcher-tui/config/config.go b/cmd/picoclaw-launcher-tui/config/config.go index 15c81f90a..28bee27cd 100644 --- a/cmd/picoclaw-launcher-tui/config/config.go +++ b/cmd/picoclaw-launcher-tui/config/config.go @@ -95,6 +95,9 @@ func Load(path string) (*TUIConfig, error) { // Save writes cfg to path atomically (safe for flash / SD storage). func Save(path string, cfg *TUIConfig) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } var buf bytes.Buffer enc := toml.NewEncoder(&buf) if err := enc.Encode(cfg); err != nil { diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go index c642a1753..b0f1799ea 100644 --- a/cmd/picoclaw-launcher-tui/ui/app.go +++ b/cmd/picoclaw-launcher-tui/ui/app.go @@ -6,6 +6,9 @@ package ui import ( + "fmt" + "sync" + "github.com/gdamore/tcell/v2" "github.com/rivo/tview" tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" @@ -13,26 +16,119 @@ import ( // App is the root TUI application. type App struct { - tapp *tview.Application - pages *tview.Pages - pageStack []string - cfg *tuicfg.TUIConfig - configPath string - homeRefreshFn func() + tapp *tview.Application + pages *tview.Pages + pageStack []string + cfg *tuicfg.TUIConfig + configPath string + pageRefreshFns map[string]func() + headerModelTV *tview.TextView + modalOpen map[string]bool + + modelCache map[string][]modelEntry + modelCacheMu sync.RWMutex + refreshMu sync.Mutex +} + +// cacheKey returns the map key for a (scheme, user) pair. +func cacheKey(schemeName, userName string) string { + return fmt.Sprintf("%s/%s", schemeName, userName) +} + +// cachedModels returns a defensive copy of the cached model list for a user (may be nil). +func (a *App) cachedModels(schemeName, userName string) []modelEntry { + a.modelCacheMu.RLock() + defer a.modelCacheMu.RUnlock() + entries := a.modelCache[cacheKey(schemeName, userName)] + return append([]modelEntry(nil), entries...) +} + +// refreshModelCache fetches models for every user in the config concurrently. +// Serialized by refreshMu so concurrent calls don't race on the cache map. +// When all fetches complete it calls onDone via QueueUpdateDraw. +func (a *App) refreshModelCache(onDone func()) { + go func() { + a.refreshMu.Lock() + defer a.refreshMu.Unlock() + + users := a.cfg.Provider.Users + schemes := a.cfg.Provider.Schemes + + schemeURL := make(map[string]string, len(schemes)) + for _, s := range schemes { + schemeURL[s.Name] = s.BaseURL + } + + var wg sync.WaitGroup + for _, u := range users { + baseURL, ok := schemeURL[u.Scheme] + if !ok || baseURL == "" { + continue + } + if u.Key == "" { + a.modelCacheMu.Lock() + if a.modelCache == nil { + a.modelCache = make(map[string][]modelEntry) + } + a.modelCache[cacheKey(u.Scheme, u.Name)] = nil + a.modelCacheMu.Unlock() + continue + } + wg.Add(1) + u := u + bURL := baseURL + go func() { + defer wg.Done() + entries, err := fetchModels(bURL, u.Key) + a.modelCacheMu.Lock() + if a.modelCache == nil { + a.modelCache = make(map[string][]modelEntry) + } + if err != nil || len(entries) == 0 { + a.modelCache[cacheKey(u.Scheme, u.Name)] = nil + } else { + a.modelCache[cacheKey(u.Scheme, u.Name)] = entries + } + a.modelCacheMu.Unlock() + }() + } + wg.Wait() + + if onDone != nil { + a.tapp.QueueUpdateDraw(onDone) + } + }() } // New creates and wires up the TUI application. func New(cfg *tuicfg.TUIConfig, configPath string) *App { + tview.Styles.PrimitiveBackgroundColor = tcell.ColorBlack + tview.Styles.ContrastBackgroundColor = tcell.ColorTeal + tview.Styles.MoreContrastBackgroundColor = tcell.ColorLime + tview.Styles.BorderColor = tcell.ColorDarkCyan + tview.Styles.TitleColor = tcell.ColorAqua + tview.Styles.GraphicsColor = tcell.ColorDarkCyan + tview.Styles.PrimaryTextColor = tcell.ColorWhite + tview.Styles.SecondaryTextColor = tcell.ColorSilver + tview.Styles.TertiaryTextColor = tcell.ColorAqua + tview.Styles.InverseTextColor = tcell.ColorBlack + tview.Styles.ContrastSecondaryTextColor = tcell.ColorNavy + a := &App{ - tapp: tview.NewApplication(), - pages: tview.NewPages(), - pageStack: []string{}, - cfg: cfg, - configPath: configPath, + tapp: tview.NewApplication(), + pages: tview.NewPages(), + pageStack: []string{}, + cfg: cfg, + configPath: configPath, + pageRefreshFns: make(map[string]func()), + modalOpen: make(map[string]bool), } a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEscape { + if len(a.modalOpen) > 0 { + return nil + } return a.goBack() } return event @@ -53,6 +149,7 @@ func (a *App) buildPages() { } func (a *App) navigateTo(name string, page tview.Primitive) { + a.pages.RemovePage(name) a.pages.AddPage(name, page, true, false) a.pageStack = append(a.pageStack, name) a.pages.SwitchToPage(name) @@ -62,26 +159,35 @@ func (a *App) goBack() *tcell.EventKey { if len(a.pageStack) <= 1 { return nil } + popped := a.pageStack[len(a.pageStack)-1] a.pageStack = a.pageStack[:len(a.pageStack)-1] + a.pages.RemovePage(popped) prev := a.pageStack[len(a.pageStack)-1] - if prev == "home" && a.homeRefreshFn != nil { - a.homeRefreshFn() + if fn, ok := a.pageRefreshFns[prev]; ok { + fn() + } + if prev == "home" && a.headerModelTV != nil { + a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ") } a.pages.SwitchToPage(prev) return nil } func (a *App) showModal(name string, primitive tview.Primitive) { + a.modalOpen[name] = true a.pages.AddPage(name, primitive, true, true) } func (a *App) hideModal(name string) { + delete(a.modalOpen, name) a.pages.HidePage(name) a.pages.RemovePage(name) } func (a *App) save() { - _ = tuicfg.Save(a.configPath, a.cfg) + if err := tuicfg.Save(a.configPath, a.cfg); err != nil { + a.showError("save failed: " + err.Error()) + } } func (a *App) showError(msg string) { @@ -91,6 +197,10 @@ func (a *App) showError(msg string) { SetDoneFunc(func(_ int, _ string) { a.hideModal("error") }) + modal.SetBackgroundColor(tcell.ColorNavy) + modal.SetTextColor(tcell.ColorWhite) + modal.SetButtonBackgroundColor(tcell.ColorDarkCyan) + modal.SetButtonTextColor(tcell.ColorWhite) a.showModal("error", modal) } @@ -104,20 +214,99 @@ func (a *App) confirmDelete(label string, onConfirm func()) { onConfirm() } }) + modal.SetBackgroundColor(tcell.ColorNavy) + modal.SetTextColor(tcell.ColorWhite) + modal.SetButtonBackgroundColor(tcell.ColorDarkCyan) + modal.SetButtonTextColor(tcell.ColorWhite) a.showModal("confirm-delete", modal) } -func centeredForm(form *tview.Form, width, height int) tview.Primitive { - return tview.NewGrid(). - SetColumns(0, width, 0). - SetRows(0, height, 0). - AddItem(form, 1, 1, 1, 1, 0, 0, true) +func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive { + return tview.NewFlex(). + AddItem(tview.NewBox(), 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(tview.NewBox(), 0, 1, false). + AddItem(form, height, 1, true). + AddItem(tview.NewBox(), 0, 1, false), 0, widthPct, true). + AddItem(tview.NewBox(), 0, 1, false) } func hintBar(text string) *tview.TextView { tv := tview.NewTextView(). SetText(text). - SetTextAlign(tview.AlignCenter) - tv.SetBackgroundColor(tcell.ColorDarkBlue) + SetTextAlign(tview.AlignCenter). + SetTextColor(tcell.ColorAqua) + tv.SetBackgroundColor(tcell.ColorMidnightBlue) return tv } + +func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tview.Primitive { + var modelTV *tview.TextView + if pageID == "home" { + if a.headerModelTV == nil { + a.headerModelTV = tview.NewTextView() + a.headerModelTV.SetTextAlign(tview.AlignRight). + SetTextColor(tcell.ColorYellow). + SetDynamicColors(true). + SetBackgroundColor(tcell.ColorBlack) + } + modelTV = a.headerModelTV + modelTV.SetText(a.cfg.CurrentModelLabel() + " ") + } else { + modelTV = tview.NewTextView() + modelTV.SetBackgroundColor(tcell.ColorBlack) + } + + headerLeft := tview.NewTextView(). + SetText(" ▓▓ PICOCLAW LAUNCHER ▓▓"). + SetTextColor(tcell.ColorAqua). + SetBackgroundColor(tcell.ColorBlack) + + header := tview.NewFlex(). + AddItem(headerLeft, 0, 1, false). + AddItem(modelTV, 0, 1, false) + + sidebar := tview.NewTextView(). + SetDynamicColors(true). + SetWrap(false) + sidebar.SetBackgroundColor(tcell.ColorNavy) + + activeColor := "[lime]▶ " + inactiveColor := "[gray] " + + sbText := "\n" + if pageID == "home" { + sbText += activeColor + "HOME[-]\n" + } else { + sbText += inactiveColor + "HOME[-]\n" + } + if pageID == "schemes" { + sbText += activeColor + "SCHEMES[-]\n" + } else { + sbText += inactiveColor + "SCHEMES[-]\n" + } + if pageID == "users" { + sbText += activeColor + "USERS[-]\n" + } else { + sbText += inactiveColor + "USERS[-]\n" + } + if pageID == "models" { + sbText += activeColor + "MODELS[-]\n" + } else { + sbText += inactiveColor + "MODELS[-]\n" + } + + sidebar.SetText(sbText) + + footer := hintBar(hint) + + grid := tview.NewGrid(). + SetRows(1, 0, 1). + SetColumns(16, 0). + AddItem(header, 0, 0, 1, 2, 0, 0, false). + AddItem(sidebar, 1, 0, 1, 1, 0, 0, false). + AddItem(content, 1, 1, 1, 1, 0, 0, true). + AddItem(footer, 2, 0, 1, 2, 0, 0, false) + + return grid +} diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go index 6235a2c8e..af25f9b43 100644 --- a/cmd/picoclaw-launcher-tui/ui/home.go +++ b/cmd/picoclaw-launcher-tui/ui/home.go @@ -12,32 +12,27 @@ import ( func (a *App) newHomePage() tview.Primitive { list := tview.NewList() - list.SetBorder(true).SetTitle(" picoclaw-launcher-tui ") + list.SetBorder(true).SetTitle(" Active Configuration ").SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan) + list.SetMainTextColor(tcell.ColorWhite) + list.SetSecondaryTextColor(tcell.ColorDarkGray) + list.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite)) + list.SetSelectedBackgroundColor(tcell.ColorTeal) + list.SetSelectedTextColor(tcell.ColorWhite) rebuildList := func() { sel := list.GetCurrentItem() list.Clear() list.AddItem("model: "+a.cfg.CurrentModelLabel(), "Enter to configure", 'm', func() { - a.pages.RemovePage("schemes") a.navigateTo("schemes", a.newSchemesPage()) }) list.AddItem("Quit", "", 'q', func() { a.tapp.Stop() }) - if sel > 0 && sel < list.GetItemCount() { + if sel >= 0 && sel < list.GetItemCount() { list.SetCurrentItem(sel) } } rebuildList() - a.homeRefreshFn = rebuildList + a.pageRefreshFns["home"] = rebuildList - list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - return event - }) - - footer := hintBar(" Enter: select q: quit ") - - return tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(list, 0, 1, true). - AddItem(footer, 1, 0, false) + return a.buildShell("home", list, " m: configure model q: quit ") } diff --git a/cmd/picoclaw-launcher-tui/ui/models.go b/cmd/picoclaw-launcher-tui/ui/models.go index 5e102d94c..c9747d544 100644 --- a/cmd/picoclaw-launcher-tui/ui/models.go +++ b/cmd/picoclaw-launcher-tui/ui/models.go @@ -33,7 +33,9 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv SetBorders(false). SetSelectable(true, false). SetFixed(0, 0) - table.SetBorder(true).SetTitle(fmt.Sprintf(" Models %s / %s ", schemeName, userName)) + table.SetBorder(true).SetTitle(fmt.Sprintf(" Models · %s / %s ", schemeName, userName)) + table.SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan) + table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite)) var modelIDs []string @@ -41,19 +43,35 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv SetTextAlign(tview.AlignCenter). SetDynamicColors(true). SetText("[yellow]Fetching models…[-]") - - footer := hintBar(" Enter: select ESC: back ") + status.SetBackgroundColor(tcell.ColorBlack) flex := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(status, 1, 0, false). - AddItem(table, 0, 1, false). - AddItem(footer, 1, 0, false) + AddItem(table, 0, 1, false) apiKey := a.resolveKey(schemeName, userName) go func() { - entries, err := fetchModels(baseURL, apiKey) + var entries []modelEntry + var err error + if apiKey == "" { + err = fmt.Errorf("key is required") + } else { + entries, err = fetchModels(baseURL, apiKey) + } + + a.modelCacheMu.Lock() + if a.modelCache == nil { + a.modelCache = make(map[string][]modelEntry) + } + if err == nil && len(entries) > 0 { + a.modelCache[cacheKey(schemeName, userName)] = entries + } else { + a.modelCache[cacheKey(schemeName, userName)] = nil + } + a.modelCacheMu.Unlock() + a.tapp.QueueUpdateDraw(func() { if err != nil { status.SetText(fmt.Sprintf("[red]Error: %s[-]", err.Error())) @@ -68,7 +86,7 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv return } - status.SetText(fmt.Sprintf("[green]%d model(s) loaded[-]", len(entries))) + status.SetText(fmt.Sprintf("[lime]%d model(s) loaded[-]", len(entries))) for i, m := range entries { modelIDs = append(modelIDs, m.ID) table.SetCell(i, 0, @@ -80,7 +98,8 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv table.SetCell(i, 1, tview.NewTableCell(" "+m.ID). SetAlign(tview.AlignLeft). - SetExpansion(1), + SetExpansion(1). + SetTextColor(tcell.ColorWhite), ) } a.tapp.SetFocus(table) @@ -100,7 +119,7 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv a.goBack() }) - return flex + return a.buildShell("models", flex, " Enter: select ESC: back ") } func (a *App) resolveKey(schemeName, userName string) string { @@ -135,9 +154,20 @@ func fetchModels(baseURL, apiKey string) ([]modelEntry, error) { return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } - var result modelsAPIResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("decode response: %w", err) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) } - return result.Data, nil + + var result modelsAPIResponse + if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 { + return result.Data, nil + } + + var arr []modelEntry + if err := json.Unmarshal(body, &arr); err == nil { + return arr, nil + } + + return nil, fmt.Errorf("decode response: unrecognised shape: %s", strings.TrimSpace(string(body[:min(len(body), 256)]))) } diff --git a/cmd/picoclaw-launcher-tui/ui/schemes.go b/cmd/picoclaw-launcher-tui/ui/schemes.go index eec3bda7c..92cae3b42 100644 --- a/cmd/picoclaw-launcher-tui/ui/schemes.go +++ b/cmd/picoclaw-launcher-tui/ui/schemes.go @@ -14,74 +14,159 @@ import ( ) func (a *App) newSchemesPage() tview.Primitive { - list := tview.NewList() - list.SetBorder(true).SetTitle(" Provider Schemes (a:add e:edit d:delete Enter:users) ") + table := tview.NewTable(). + SetBorders(false). + SetSelectable(true, false) + table.SetBorder(true).SetTitle(" Provider Schemes ") + table.SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan) + table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite)) + + rowToIdx := func(row int) int { return row / 2 } + + selectedSchemeName := func() string { + row, _ := table.GetSelection() + idx := rowToIdx(row) + schemes := a.cfg.Provider.Schemes + if idx >= 0 && idx < len(schemes) { + return schemes[idx].Name + } + return "" + } rebuild := func() { - sel := list.GetCurrentItem() - list.Clear() - for _, s := range a.cfg.Provider.Schemes { - name := s.Name - list.AddItem( - fmt.Sprintf("%s · %s [%s]", s.Name, s.BaseURL, s.Type), - "", - 0, - func() { - a.pages.RemovePage("users") - a.navigateTo("users", a.newUsersPage(name)) - }, + selName := selectedSchemeName() + table.Clear() + schemes := a.cfg.Provider.Schemes + for i, s := range schemes { + nameRow := i * 2 + detailRow := nameRow + 1 + + table.SetCell(nameRow, 0, + tview.NewTableCell(" "+s.Name). + SetTextColor(tcell.ColorWhite). + SetExpansion(1). + SetSelectable(true), + ) + + users := a.cfg.Provider.UsersForScheme(s.Name) + n := len(users) + m := 0 + for _, u := range users { + if models := a.cachedModels(s.Name, u.Name); len(models) > 0 { + m++ + } + } + table.SetCell(detailRow, 0, + tview.NewTableCell(fmt.Sprintf(" (%d/%d)%s", m, n, s.BaseURL)). + SetTextColor(tcell.ColorDarkGray). + SetExpansion(1). + SetSelectable(false), + ) + table.SetCell(detailRow, 1, + tview.NewTableCell(s.Type+" "). + SetTextColor(tcell.ColorDarkGray). + SetAlign(tview.AlignRight). + SetSelectable(false), ) } - if sel >= 0 && sel < list.GetItemCount() { - list.SetCurrentItem(sel) + if selName != "" { + for i, s := range schemes { + if s.Name == selName { + table.Select(i*2, 0) + return + } + } + } + if table.GetRowCount() > 0 { + table.Select(0, 0) } } rebuild() - list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + a.refreshModelCache(rebuild) + a.pageRefreshFns["schemes"] = func() { a.refreshModelCache(rebuild) } + + table.SetSelectedFunc(func(row, _ int) { + idx := rowToIdx(row) + schemes := a.cfg.Provider.Schemes + if idx < 0 || idx >= len(schemes) { + return + } + name := schemes[idx].Name + a.navigateTo("users", a.newUsersPage(name)) + }) + + table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + row, _ := table.GetSelection() + idx := rowToIdx(row) + schemes := a.cfg.Provider.Schemes switch event.Rune() { case 'a': a.showSchemeForm(nil, func(s tuicfg.Scheme) { a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s) a.save() - rebuild() + a.refreshModelCache(rebuild) }) return nil case 'e': - idx := list.GetCurrentItem() - if idx < 0 || idx >= len(a.cfg.Provider.Schemes) { + if idx < 0 || idx >= len(schemes) { return nil } - orig := a.cfg.Provider.Schemes[idx] + origName := schemes[idx].Name + orig := schemes[idx] a.showSchemeForm(&orig, func(s tuicfg.Scheme) { - a.cfg.Provider.Schemes[idx] = s + current := a.cfg.Provider.Schemes + for i, sc := range current { + if sc.Name == origName { + a.cfg.Provider.Schemes[i] = s + break + } + } a.save() - rebuild() + a.refreshModelCache(func() { + rebuild() + for i, sc := range a.cfg.Provider.Schemes { + if sc.Name == s.Name { + table.Select(i*2, 0) + break + } + } + }) }) return nil case 'd': - idx := list.GetCurrentItem() - if idx < 0 || idx >= len(a.cfg.Provider.Schemes) { + if idx < 0 || idx >= len(schemes) { return nil } - name := a.cfg.Provider.Schemes[idx].Name + name := schemes[idx].Name a.confirmDelete(fmt.Sprintf("scheme %q", name), func() { - schemes := a.cfg.Provider.Schemes - a.cfg.Provider.Schemes = append(schemes[:idx], schemes[idx+1:]...) + current := a.cfg.Provider.Schemes + newSchemes := make([]tuicfg.Scheme, 0, len(current)) + for _, sc := range current { + if sc.Name != name { + newSchemes = append(newSchemes, sc) + } + } + a.cfg.Provider.Schemes = newSchemes + + existing := a.cfg.Provider.Users + filtered := make([]tuicfg.User, 0, len(existing)) + for _, u := range existing { + if u.Scheme != name { + filtered = append(filtered, u) + } + } + a.cfg.Provider.Users = filtered + a.save() - rebuild() + a.refreshModelCache(rebuild) }) return nil } return event }) - footer := hintBar(" Enter: users a: add e: edit d: delete ESC: back ") - - return tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(list, 0, 1, true). - AddItem(footer, 1, 0, false) + return a.buildShell("schemes", table, " a: add e: edit d: delete Enter: open ESC: back ") } func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) { @@ -108,14 +193,11 @@ func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme) form := tview.NewForm() - var nameField *tview.InputField - form. - AddInputField("Name", name, 40, nil, func(text string) { name = text }). - AddInputField("Base URL", baseURL, 60, nil, func(text string) { baseURL = text }). + AddInputField("Name", name, 32, nil, func(text string) { name = text }). + AddInputField("Base URL", baseURL, 32, nil, func(text string) { baseURL = text }). AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }). AddButton("Save", func() { - _ = nameField if name == "" { a.showError("Name is required") return @@ -139,9 +221,20 @@ func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme) a.hideModal("scheme-form") }) - nameField, _ = form.GetFormItemByLabel("Name").(*tview.InputField) + form.SetBorder(true).SetTitle(title).SetTitleColor(tcell.ColorLime) + form.SetBorderColor(tcell.ColorDarkCyan) + form.SetFieldBackgroundColor(tcell.ColorBlack) + form.SetFieldTextColor(tcell.ColorWhite) + form.SetLabelColor(tcell.ColorAqua) + form.SetButtonBackgroundColor(tcell.ColorDarkCyan) + form.SetButtonTextColor(tcell.ColorWhite) + form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + a.hideModal("scheme-form") + return nil + } + return event + }) - form.SetBorder(true).SetTitle(title) - - a.showModal("scheme-form", centeredForm(form, 68, 12)) + a.showModal("scheme-form", centeredForm(form, 6, 12)) } diff --git a/cmd/picoclaw-launcher-tui/ui/users.go b/cmd/picoclaw-launcher-tui/ui/users.go index 27b7cea7a..f561938d5 100644 --- a/cmd/picoclaw-launcher-tui/ui/users.go +++ b/cmd/picoclaw-launcher-tui/ui/users.go @@ -14,98 +14,173 @@ import ( ) func (a *App) newUsersPage(schemeName string) tview.Primitive { - list := tview.NewList() - list.SetBorder(true).SetTitle(fmt.Sprintf(" Users for scheme %q (a:add e:edit d:delete Enter:models) ", schemeName)) + table := tview.NewTable(). + SetBorders(false). + SetSelectable(true, false) + table.SetBorder(true).SetTitle(fmt.Sprintf(" Users · %s ", schemeName)) + table.SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan) + table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite)) - indexInCfg := func(visibleIdx int) int { - count := 0 - for i, u := range a.cfg.Provider.Users { + visibleUsers := func() []tuicfg.User { + var out []tuicfg.User + for _, u := range a.cfg.Provider.Users { if u.Scheme == schemeName { - if count == visibleIdx { - return i - } - count++ + out = append(out, u) + } + } + return out + } + + findUserGlobalIdx := func(userName string) int { + for i, u := range a.cfg.Provider.Users { + if u.Scheme == schemeName && u.Name == userName { + return i } } return -1 } + rowToVisIdx := func(row int) int { return row / 2 } + + selectedUserName := func() string { + row, _ := table.GetSelection() + users := visibleUsers() + visIdx := rowToVisIdx(row) + if visIdx >= 0 && visIdx < len(users) { + return users[visIdx].Name + } + return "" + } + rebuild := func() { - sel := list.GetCurrentItem() - list.Clear() - for _, u := range a.cfg.Provider.Users { - if u.Scheme != schemeName { - continue + selName := selectedUserName() + table.Clear() + users := visibleUsers() + for i, u := range users { + nameRow := i * 2 + detailRow := nameRow + 1 + + table.SetCell(nameRow, 0, + tview.NewTableCell(" "+u.Name). + SetTextColor(tcell.ColorWhite). + SetExpansion(1). + SetSelectable(true), + ) + table.SetCell(nameRow, 1, + tview.NewTableCell(""). + SetSelectable(false), + ) + + models := a.cachedModels(schemeName, u.Name) + var detailText string + if len(models) > 0 { + detailText = fmt.Sprintf(" %d models", len(models)) + } else { + detailText = " [red]Inactive[-]" } - uName := u.Name - uType := u.Type - list.AddItem( - fmt.Sprintf("%s · %s", u.Name, uType), - "", - 0, - func() { - a.pages.RemovePage("models") - scheme := a.cfg.Provider.SchemeByName(schemeName) - if scheme == nil { - a.showError(fmt.Sprintf("Scheme %q not found", schemeName)) - return - } - a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL)) - }, + table.SetCell(detailRow, 0, + tview.NewTableCell(detailText). + SetTextColor(tcell.ColorDarkGray). + SetExpansion(1). + SetSelectable(false), + ) + table.SetCell(detailRow, 1, + tview.NewTableCell(u.Type+" "). + SetTextColor(tcell.ColorDarkGray). + SetAlign(tview.AlignRight). + SetSelectable(false), ) } - if sel >= 0 && sel < list.GetItemCount() { - list.SetCurrentItem(sel) + if selName != "" { + for i, u := range users { + if u.Name == selName { + table.Select(i*2, 0) + return + } + } + } + if table.GetRowCount() > 0 { + table.Select(0, 0) } } rebuild() - list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + a.refreshModelCache(rebuild) + a.pageRefreshFns["users"] = func() { a.refreshModelCache(rebuild) } + + table.SetSelectedFunc(func(row, _ int) { + visIdx := rowToVisIdx(row) + users := visibleUsers() + if visIdx < 0 || visIdx >= len(users) { + return + } + uName := users[visIdx].Name + scheme := a.cfg.Provider.SchemeByName(schemeName) + if scheme == nil { + a.showError(fmt.Sprintf("Scheme %q not found", schemeName)) + return + } + a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL)) + }) + + table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + row, _ := table.GetSelection() + visIdx := rowToVisIdx(row) + users := visibleUsers() switch event.Rune() { case 'a': a.showUserForm(schemeName, nil, func(u tuicfg.User) { a.cfg.Provider.Users = append(a.cfg.Provider.Users, u) a.save() - rebuild() + a.refreshModelCache(rebuild) }) return nil case 'e': - visIdx := list.GetCurrentItem() - cfgIdx := indexInCfg(visIdx) - if cfgIdx < 0 { + if visIdx < 0 || visIdx >= len(users) { return nil } - orig := a.cfg.Provider.Users[cfgIdx] + origName := users[visIdx].Name + orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)] a.showUserForm(schemeName, &orig, func(u tuicfg.User) { + cfgIdx := findUserGlobalIdx(origName) + if cfgIdx < 0 { + a.showError(fmt.Sprintf("User %q no longer exists", origName)) + return + } a.cfg.Provider.Users[cfgIdx] = u a.save() - rebuild() + a.refreshModelCache(func() { + rebuild() + for i, usr := range visibleUsers() { + if usr.Name == u.Name { + table.Select(i*2, 0) + break + } + } + }) }) return nil case 'd': - visIdx := list.GetCurrentItem() - cfgIdx := indexInCfg(visIdx) - if cfgIdx < 0 { + if visIdx < 0 || visIdx >= len(users) { return nil } - uName := a.cfg.Provider.Users[cfgIdx].Name + uName := users[visIdx].Name a.confirmDelete(fmt.Sprintf("user %q", uName), func() { - users := a.cfg.Provider.Users - a.cfg.Provider.Users = append(users[:cfgIdx], users[cfgIdx+1:]...) + cfgIdx := findUserGlobalIdx(uName) + if cfgIdx < 0 { + return + } + all := a.cfg.Provider.Users + a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...) a.save() - rebuild() + a.refreshModelCache(rebuild) }) return nil } return event }) - footer := hintBar(" Enter: select model a: add e: edit d: delete ESC: back ") - - return tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(list, 0, 1, true). - AddItem(footer, 1, 0, false) + return a.buildShell("users", table, " a: add e: edit d: delete Enter: models ESC: back ") } func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) { @@ -132,9 +207,9 @@ func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func form := tview.NewForm() form. - AddInputField("Name", name, 40, nil, func(text string) { name = text }). + AddInputField("Name", name, 32, nil, func(text string) { name = text }). AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }). - AddPasswordField("Key", key, 60, '*', func(text string) { key = text }). + AddPasswordField("Key", key, 32, '*', func(text string) { key = text }). AddButton("Save", func() { if name == "" { a.showError("Name is required") @@ -155,7 +230,20 @@ func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func a.hideModal("user-form") }) - form.SetBorder(true).SetTitle(title) + form.SetBorder(true).SetTitle(title).SetTitleColor(tcell.ColorLime) + form.SetBorderColor(tcell.ColorDarkCyan) + form.SetFieldBackgroundColor(tcell.ColorBlack) + form.SetFieldTextColor(tcell.ColorWhite) + form.SetLabelColor(tcell.ColorAqua) + form.SetButtonBackgroundColor(tcell.ColorDarkCyan) + form.SetButtonTextColor(tcell.ColorWhite) + form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + a.hideModal("user-form") + return nil + } + return event + }) - a.showModal("user-form", centeredForm(form, 68, 13)) + a.showModal("user-form", centeredForm(form, 6, 13)) } From 74a145c29114820a72bb94f56de6f2873334dc1c Mon Sep 17 00:00:00 2001 From: taorye Date: Fri, 20 Mar 2026 17:04:57 +0800 Subject: [PATCH 04/10] style: apply cyberpunk theme to TUI components for enhanced visual appeal --- cmd/picoclaw-launcher-tui/ui/app.go | 117 +++++++++++++----------- cmd/picoclaw-launcher-tui/ui/home.go | 18 ++-- cmd/picoclaw-launcher-tui/ui/models.go | 26 +++--- cmd/picoclaw-launcher-tui/ui/schemes.go | 45 +++++---- cmd/picoclaw-launcher-tui/ui/users.go | 47 +++++----- 5 files changed, 129 insertions(+), 124 deletions(-) diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go index b0f1799ea..53d1cf8cd 100644 --- a/cmd/picoclaw-launcher-tui/ui/app.go +++ b/cmd/picoclaw-launcher-tui/ui/app.go @@ -102,17 +102,23 @@ func (a *App) refreshModelCache(onDone func()) { // New creates and wires up the TUI application. func New(cfg *tuicfg.TUIConfig, configPath string) *App { - tview.Styles.PrimitiveBackgroundColor = tcell.ColorBlack - tview.Styles.ContrastBackgroundColor = tcell.ColorTeal - tview.Styles.MoreContrastBackgroundColor = tcell.ColorLime - tview.Styles.BorderColor = tcell.ColorDarkCyan - tview.Styles.TitleColor = tcell.ColorAqua - tview.Styles.GraphicsColor = tcell.ColorDarkCyan - tview.Styles.PrimaryTextColor = tcell.ColorWhite - tview.Styles.SecondaryTextColor = tcell.ColorSilver - tview.Styles.TertiaryTextColor = tcell.ColorAqua - tview.Styles.InverseTextColor = tcell.ColorBlack - tview.Styles.ContrastSecondaryTextColor = tcell.ColorNavy + // Cyberpunk Theme Colors + // Dark background + tview.Styles.PrimitiveBackgroundColor = tcell.NewHexColor(0x050510) // Deep Void + tview.Styles.ContrastBackgroundColor = tcell.NewHexColor(0x1a1a2e) // Dark Indigo + tview.Styles.MoreContrastBackgroundColor = tcell.NewHexColor(0x2a2a40) + + // Borders and Titles + tview.Styles.BorderColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan + tview.Styles.TitleColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan + tview.Styles.GraphicsColor = tcell.NewHexColor(0xff00ff) // Neon Magenta + + // Text + tview.Styles.PrimaryTextColor = tcell.NewHexColor(0xe0e0e0) // Off-white + tview.Styles.SecondaryTextColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan + tview.Styles.TertiaryTextColor = tcell.NewHexColor(0x39ff14) // Neon Lime + tview.Styles.InverseTextColor = tcell.NewHexColor(0x000000) // Black + tview.Styles.ContrastSecondaryTextColor = tcell.NewHexColor(0xff00ff) // Neon Magenta a := &App{ tapp: tview.NewApplication(), @@ -127,7 +133,7 @@ func New(cfg *tuicfg.TUIConfig, configPath string) *App { a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEscape { if len(a.modalOpen) > 0 { - return nil + return event } return a.goBack() } @@ -192,21 +198,22 @@ func (a *App) save() { func (a *App) showError(msg string) { modal := tview.NewModal(). - SetText("Error: " + msg). + SetText(" [red::b]ERROR[-::-]\n\n" + msg). AddButtons([]string{"OK"}). SetDoneFunc(func(_ int, _ string) { a.hideModal("error") }) - modal.SetBackgroundColor(tcell.ColorNavy) - modal.SetTextColor(tcell.ColorWhite) - modal.SetButtonBackgroundColor(tcell.ColorDarkCyan) - modal.SetButtonTextColor(tcell.ColorWhite) + // Cyberpunk Modal Style + modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo + modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White + modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red + modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White a.showModal("error", modal) } func (a *App) confirmDelete(label string, onConfirm func()) { modal := tview.NewModal(). - SetText("Delete " + label + "?\nThis cannot be undone."). + SetText(" [red::b]DELETE WARNING[-::-]\n\nDelete " + label + "?\n[gray]This action cannot be undone.[-]"). AddButtons([]string{"Delete", "Cancel"}). SetDoneFunc(func(_ int, buttonLabel string) { a.hideModal("confirm-delete") @@ -214,10 +221,11 @@ func (a *App) confirmDelete(label string, onConfirm func()) { onConfirm() } }) - modal.SetBackgroundColor(tcell.ColorNavy) - modal.SetTextColor(tcell.ColorWhite) - modal.SetButtonBackgroundColor(tcell.ColorDarkCyan) - modal.SetButtonTextColor(tcell.ColorWhite) + // Cyberpunk Modal Style + modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo + modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White + modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red for danger + modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White a.showModal("confirm-delete", modal) } @@ -234,9 +242,10 @@ func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive { func hintBar(text string) *tview.TextView { tv := tview.NewTextView(). SetText(text). + SetDynamicColors(true). SetTextAlign(tview.AlignCenter). - SetTextColor(tcell.ColorAqua) - tv.SetBackgroundColor(tcell.ColorMidnightBlue) + SetTextColor(tcell.NewHexColor(0x00f0ff)) // Neon Cyan + tv.SetBackgroundColor(tcell.NewHexColor(0x2a2a40)) // Darker Indigo return tv } @@ -246,21 +255,21 @@ func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tv if a.headerModelTV == nil { a.headerModelTV = tview.NewTextView() a.headerModelTV.SetTextAlign(tview.AlignRight). - SetTextColor(tcell.ColorYellow). + SetTextColor(tcell.NewHexColor(0x39ff14)). // Neon Lime SetDynamicColors(true). - SetBackgroundColor(tcell.ColorBlack) + SetBackgroundColor(tcell.NewHexColor(0x050510)) } modelTV = a.headerModelTV - modelTV.SetText(a.cfg.CurrentModelLabel() + " ") + modelTV.SetText("MODEL: " + a.cfg.CurrentModelLabel() + " ") } else { modelTV = tview.NewTextView() - modelTV.SetBackgroundColor(tcell.ColorBlack) + modelTV.SetBackgroundColor(tcell.NewHexColor(0x050510)) } headerLeft := tview.NewTextView(). - SetText(" ▓▓ PICOCLAW LAUNCHER ▓▓"). - SetTextColor(tcell.ColorAqua). - SetBackgroundColor(tcell.ColorBlack) + SetText(" [#ff00ff::b]///[#00f0ff] PICOCLAW LAUNCHER [#ff00ff]///"). + SetDynamicColors(true). + SetBackgroundColor(tcell.NewHexColor(0x050510)) header := tview.NewFlex(). AddItem(headerLeft, 0, 1, false). @@ -269,44 +278,42 @@ func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tv sidebar := tview.NewTextView(). SetDynamicColors(true). SetWrap(false) - sidebar.SetBackgroundColor(tcell.ColorNavy) + sidebar.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo - activeColor := "[lime]▶ " - inactiveColor := "[gray] " + // Cyberpunk Sidebar Styling + activePrefix := "[#39ff14::b]>> " // Neon Lime arrow + activeSuffix := "[-]" + inactivePrefix := "[#808080] " + inactiveSuffix := "[-]" - sbText := "\n" - if pageID == "home" { - sbText += activeColor + "HOME[-]\n" - } else { - sbText += inactiveColor + "HOME[-]\n" - } - if pageID == "schemes" { - sbText += activeColor + "SCHEMES[-]\n" - } else { - sbText += inactiveColor + "SCHEMES[-]\n" - } - if pageID == "users" { - sbText += activeColor + "USERS[-]\n" - } else { - sbText += inactiveColor + "USERS[-]\n" - } - if pageID == "models" { - sbText += activeColor + "MODELS[-]\n" - } else { - sbText += inactiveColor + "MODELS[-]\n" + sbText := "\n\n" // Top padding + + menuItem := func(id, label string) string { + if pageID == id { + return activePrefix + label + activeSuffix + "\n\n" + } + return inactivePrefix + label + inactiveSuffix + "\n\n" } + sbText += menuItem("home", "HOME") + sbText += menuItem("schemes", "SCHEMES") + sbText += menuItem("users", "USERS") + sbText += menuItem("models", "MODELS") + sidebar.SetText(sbText) footer := hintBar(hint) grid := tview.NewGrid(). SetRows(1, 0, 1). - SetColumns(16, 0). + SetColumns(20, 0). // Slightly wider sidebar AddItem(header, 0, 0, 1, 2, 0, 0, false). AddItem(sidebar, 1, 0, 1, 1, 0, 0, false). AddItem(content, 1, 1, 1, 1, 0, 0, true). AddItem(footer, 2, 0, 1, 2, 0, 0, false) + // Add a border around the content area if possible, or ensure content has its own border + // grid.SetBorders(false) // Grid borders usually look bad, handled by components + return grid } diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go index af25f9b43..4e952d534 100644 --- a/cmd/picoclaw-launcher-tui/ui/home.go +++ b/cmd/picoclaw-launcher-tui/ui/home.go @@ -12,20 +12,20 @@ import ( func (a *App) newHomePage() tview.Primitive { list := tview.NewList() - list.SetBorder(true).SetTitle(" Active Configuration ").SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan) - list.SetMainTextColor(tcell.ColorWhite) - list.SetSecondaryTextColor(tcell.ColorDarkGray) - list.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite)) - list.SetSelectedBackgroundColor(tcell.ColorTeal) - list.SetSelectedTextColor(tcell.ColorWhite) + list.SetBorder(true).SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION ").SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff)) + list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0)) + list.SetSecondaryTextColor(tcell.NewHexColor(0x808080)) + list.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510))) + list.SetHighlightFullLine(true) + list.SetBackgroundColor(tcell.NewHexColor(0x050510)) rebuildList := func() { sel := list.GetCurrentItem() list.Clear() - list.AddItem("model: "+a.cfg.CurrentModelLabel(), "Enter to configure", 'm', func() { + list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() { a.navigateTo("schemes", a.newSchemesPage()) }) - list.AddItem("Quit", "", 'q', func() { a.tapp.Stop() }) + list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() }) if sel >= 0 && sel < list.GetItemCount() { list.SetCurrentItem(sel) } @@ -34,5 +34,5 @@ func (a *App) newHomePage() tview.Primitive { a.pageRefreshFns["home"] = rebuildList - return a.buildShell("home", list, " m: configure model q: quit ") + return a.buildShell("home", list, " [#00f0ff]m:[-] configure model [#ff2a2a]q:[-] quit ") } diff --git a/cmd/picoclaw-launcher-tui/ui/models.go b/cmd/picoclaw-launcher-tui/ui/models.go index c9747d544..46daaeb3e 100644 --- a/cmd/picoclaw-launcher-tui/ui/models.go +++ b/cmd/picoclaw-launcher-tui/ui/models.go @@ -33,17 +33,17 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv SetBorders(false). SetSelectable(true, false). SetFixed(0, 0) - table.SetBorder(true).SetTitle(fmt.Sprintf(" Models · %s / %s ", schemeName, userName)) - table.SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan) - table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite)) + table.SetBorder(true).SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)).SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff)) + table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff))) + table.SetBackgroundColor(tcell.NewHexColor(0x050510)) var modelIDs []string status := tview.NewTextView(). SetTextAlign(tview.AlignCenter). SetDynamicColors(true). - SetText("[yellow]Fetching models…[-]") - status.SetBackgroundColor(tcell.ColorBlack) + SetText("[#ffff00]FETCHING MODELS...[-]") + status.SetBackgroundColor(tcell.NewHexColor(0x050510)) flex := tview.NewFlex(). SetDirection(tview.FlexRow). @@ -74,32 +74,32 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv a.tapp.QueueUpdateDraw(func() { if err != nil { - status.SetText(fmt.Sprintf("[red]Error: %s[-]", err.Error())) - table.SetCell(0, 0, tview.NewTableCell("(failed to load models)")) + status.SetText(fmt.Sprintf("[#ff2a2a]ERROR: %s[-]", err.Error())) + table.SetCell(0, 0, tview.NewTableCell(" (failed to load models)")) a.tapp.SetFocus(table) return } if len(entries) == 0 { - status.SetText("[yellow]No models returned[-]") - table.SetCell(0, 0, tview.NewTableCell("(no models available)")) + status.SetText("[#ff2a2a]NO MODELS RETURNED[-]") + table.SetCell(0, 0, tview.NewTableCell(" (no models available)")) a.tapp.SetFocus(table) return } - status.SetText(fmt.Sprintf("[lime]%d model(s) loaded[-]", len(entries))) + status.SetText(fmt.Sprintf("[#39ff14]%d MODEL(S) LOADED[-]", len(entries))) for i, m := range entries { modelIDs = append(modelIDs, m.ID) table.SetCell(i, 0, tview.NewTableCell(fmt.Sprintf("%3d", i+1)). SetAlign(tview.AlignRight). - SetTextColor(tcell.ColorGray). + SetTextColor(tcell.NewHexColor(0x808080)). SetSelectable(false), ) table.SetCell(i, 1, tview.NewTableCell(" "+m.ID). SetAlign(tview.AlignLeft). SetExpansion(1). - SetTextColor(tcell.ColorWhite), + SetTextColor(tcell.NewHexColor(0xe0e0e0)), ) } a.tapp.SetFocus(table) @@ -119,7 +119,7 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv a.goBack() }) - return a.buildShell("models", flex, " Enter: select ESC: back ") + return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ") } func (a *App) resolveKey(schemeName, userName string) string { diff --git a/cmd/picoclaw-launcher-tui/ui/schemes.go b/cmd/picoclaw-launcher-tui/ui/schemes.go index 92cae3b42..70375eccc 100644 --- a/cmd/picoclaw-launcher-tui/ui/schemes.go +++ b/cmd/picoclaw-launcher-tui/ui/schemes.go @@ -17,9 +17,9 @@ func (a *App) newSchemesPage() tview.Primitive { table := tview.NewTable(). SetBorders(false). SetSelectable(true, false) - table.SetBorder(true).SetTitle(" Provider Schemes ") - table.SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan) - table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite)) + table.SetBorder(true).SetTitle(" [#00f0ff::b] PROVIDER SCHEMES ").SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff)) + table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff))) + table.SetBackgroundColor(tcell.NewHexColor(0x050510)) rowToIdx := func(row int) int { return row / 2 } @@ -43,7 +43,7 @@ func (a *App) newSchemesPage() tview.Primitive { table.SetCell(nameRow, 0, tview.NewTableCell(" "+s.Name). - SetTextColor(tcell.ColorWhite). + SetTextColor(tcell.NewHexColor(0xe0e0e0)). SetExpansion(1). SetSelectable(true), ) @@ -57,14 +57,13 @@ func (a *App) newSchemesPage() tview.Primitive { } } table.SetCell(detailRow, 0, - tview.NewTableCell(fmt.Sprintf(" (%d/%d)%s", m, n, s.BaseURL)). - SetTextColor(tcell.ColorDarkGray). + tview.NewTableCell(fmt.Sprintf(" [#808080](%d/%d) %s", m, n, s.BaseURL)). + SetTextColor(tcell.NewHexColor(0x808080)). SetExpansion(1). SetSelectable(false), ) table.SetCell(detailRow, 1, - tview.NewTableCell(s.Type+" "). - SetTextColor(tcell.ColorDarkGray). + tview.NewTableCell("[#00f0ff]"+s.Type+" "). SetAlign(tview.AlignRight). SetSelectable(false), ) @@ -166,20 +165,20 @@ func (a *App) newSchemesPage() tview.Primitive { return event }) - return a.buildShell("schemes", table, " a: add e: edit d: delete Enter: open ESC: back ") + return a.buildShell("schemes", table, " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ") } func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) { name := "" baseURL := "" schemeType := "openai-compatible" - title := " Add Scheme " + title := " ADD SCHEME " if existing != nil { name = existing.Name baseURL = existing.BaseURL schemeType = existing.Type - title = " Edit Scheme " + title = " EDIT SCHEME " } typeOptions := []string{"openai-compatible", "anthropic"} @@ -194,10 +193,10 @@ func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme) form := tview.NewForm() form. - AddInputField("Name", name, 32, nil, func(text string) { name = text }). - AddInputField("Base URL", baseURL, 32, nil, func(text string) { baseURL = text }). + AddInputField("Name", name, 20, nil, func(text string) { name = text }). + AddInputField("Base URL", baseURL, 28, nil, func(text string) { baseURL = text }). AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }). - AddButton("Save", func() { + AddButton("SAVE", func() { if name == "" { a.showError("Name is required") return @@ -217,17 +216,17 @@ func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme) a.hideModal("scheme-form") onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType}) }). - AddButton("Cancel", func() { + AddButton("CANCEL", func() { a.hideModal("scheme-form") }) - form.SetBorder(true).SetTitle(title).SetTitleColor(tcell.ColorLime) - form.SetBorderColor(tcell.ColorDarkCyan) - form.SetFieldBackgroundColor(tcell.ColorBlack) - form.SetFieldTextColor(tcell.ColorWhite) - form.SetLabelColor(tcell.ColorAqua) - form.SetButtonBackgroundColor(tcell.ColorDarkCyan) - form.SetButtonTextColor(tcell.ColorWhite) + form.SetBorder(true).SetTitle(" [::b]" + title + " ").SetTitleColor(tcell.NewHexColor(0x39ff14)).SetBorderColor(tcell.NewHexColor(0x00f0ff)) + form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) + form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510)) + form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff)) + form.SetLabelColor(tcell.NewHexColor(0xe0e0e0)) + form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff)) + form.SetButtonTextColor(tcell.NewHexColor(0xffffff)) form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEscape { a.hideModal("scheme-form") @@ -236,5 +235,5 @@ func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme) return event }) - a.showModal("scheme-form", centeredForm(form, 6, 12)) + a.showModal("scheme-form", centeredForm(form, 4, 12)) } diff --git a/cmd/picoclaw-launcher-tui/ui/users.go b/cmd/picoclaw-launcher-tui/ui/users.go index f561938d5..4a877d3c7 100644 --- a/cmd/picoclaw-launcher-tui/ui/users.go +++ b/cmd/picoclaw-launcher-tui/ui/users.go @@ -17,9 +17,9 @@ func (a *App) newUsersPage(schemeName string) tview.Primitive { table := tview.NewTable(). SetBorders(false). SetSelectable(true, false) - table.SetBorder(true).SetTitle(fmt.Sprintf(" Users · %s ", schemeName)) - table.SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan) - table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite)) + table.SetBorder(true).SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)).SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff)) + table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff))) + table.SetBackgroundColor(tcell.NewHexColor(0x050510)) visibleUsers := func() []tuicfg.User { var out []tuicfg.User @@ -62,7 +62,7 @@ func (a *App) newUsersPage(schemeName string) tview.Primitive { table.SetCell(nameRow, 0, tview.NewTableCell(" "+u.Name). - SetTextColor(tcell.ColorWhite). + SetTextColor(tcell.NewHexColor(0xe0e0e0)). SetExpansion(1). SetSelectable(true), ) @@ -74,19 +74,18 @@ func (a *App) newUsersPage(schemeName string) tview.Primitive { models := a.cachedModels(schemeName, u.Name) var detailText string if len(models) > 0 { - detailText = fmt.Sprintf(" %d models", len(models)) + detailText = fmt.Sprintf(" [#39ff14]%d models available[-]", len(models)) } else { - detailText = " [red]Inactive[-]" + detailText = " [#ff2a2a]Inactive / No Access[-]" } table.SetCell(detailRow, 0, tview.NewTableCell(detailText). - SetTextColor(tcell.ColorDarkGray). + SetTextColor(tcell.NewHexColor(0x808080)). SetExpansion(1). SetSelectable(false), ) table.SetCell(detailRow, 1, - tview.NewTableCell(u.Type+" "). - SetTextColor(tcell.ColorDarkGray). + tview.NewTableCell("[#00f0ff]"+u.Type+" "). SetAlign(tview.AlignRight). SetSelectable(false), ) @@ -180,20 +179,20 @@ func (a *App) newUsersPage(schemeName string) tview.Primitive { return event }) - return a.buildShell("users", table, " a: add e: edit d: delete Enter: models ESC: back ") + return a.buildShell("users", table, " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ") } func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) { name := "" userType := "key" key := "" - title := " Add User " + title := " ADD USER " if existing != nil { name = existing.Name userType = existing.Type key = existing.Key - title = " Edit User " + title = " EDIT USER " } typeOptions := []string{"key", "OAuth"} @@ -207,10 +206,10 @@ func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func form := tview.NewForm() form. - AddInputField("Name", name, 32, nil, func(text string) { name = text }). + AddInputField("Name", name, 20, nil, func(text string) { name = text }). AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }). - AddPasswordField("Key", key, 32, '*', func(text string) { key = text }). - AddButton("Save", func() { + AddPasswordField("Key", key, 28, '*', func(text string) { key = text }). + AddButton("SAVE", func() { if name == "" { a.showError("Name is required") return @@ -226,17 +225,17 @@ func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func a.hideModal("user-form") onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key}) }). - AddButton("Cancel", func() { + AddButton("CANCEL", func() { a.hideModal("user-form") }) - form.SetBorder(true).SetTitle(title).SetTitleColor(tcell.ColorLime) - form.SetBorderColor(tcell.ColorDarkCyan) - form.SetFieldBackgroundColor(tcell.ColorBlack) - form.SetFieldTextColor(tcell.ColorWhite) - form.SetLabelColor(tcell.ColorAqua) - form.SetButtonBackgroundColor(tcell.ColorDarkCyan) - form.SetButtonTextColor(tcell.ColorWhite) + form.SetBorder(true).SetTitle(" [::b]" + title + " ").SetTitleColor(tcell.NewHexColor(0x39ff14)).SetBorderColor(tcell.NewHexColor(0x00f0ff)) + form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) + form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510)) + form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff)) + form.SetLabelColor(tcell.NewHexColor(0xe0e0e0)) + form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff)) + form.SetButtonTextColor(tcell.NewHexColor(0xffffff)) form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEscape { a.hideModal("user-form") @@ -245,5 +244,5 @@ func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func return event }) - a.showModal("user-form", centeredForm(form, 6, 13)) + a.showModal("user-form", centeredForm(form, 4, 13)) } From 545b7afe41ecff8df64ed9464f48e95d93df1d4b Mon Sep 17 00:00:00 2001 From: taorye Date: Fri, 20 Mar 2026 17:37:04 +0800 Subject: [PATCH 05/10] feat: add model selection synchronization to main config in TUI --- cmd/picoclaw-launcher-tui/config/config.go | 73 ++++++++++++++++++++++ cmd/picoclaw-launcher-tui/main.go | 4 ++ cmd/picoclaw-launcher-tui/ui/app.go | 4 ++ cmd/picoclaw-launcher-tui/ui/models.go | 18 ++++++ 4 files changed, 99 insertions(+) diff --git a/cmd/picoclaw-launcher-tui/config/config.go b/cmd/picoclaw-launcher-tui/config/config.go index 28bee27cd..64d479285 100644 --- a/cmd/picoclaw-launcher-tui/config/config.go +++ b/cmd/picoclaw-launcher-tui/config/config.go @@ -8,6 +8,7 @@ package config import ( "bytes" + "encoding/json" "fmt" "os" "path/filepath" @@ -149,6 +150,78 @@ func (p *Provider) UsersForScheme(schemeName string) []User { return out } +// SyncSelectedModelToMainConfig syncs the currently selected model to ~/.picoclaw/config.json +// Adds/replaces a "tui-prefer" model entry and sets it as the default model. +// Preserves all other existing fields in the config file unchanged. +func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) error { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + mainConfigPath := filepath.Join(home, ".picoclaw", "config.json") + + var cfg map[string]interface{} + if data, err := os.ReadFile(mainConfigPath); err == nil { + if err := json.Unmarshal(data, &cfg); err != nil { + cfg = make(map[string]interface{}) + } + } else { + cfg = make(map[string]interface{}) + } + + if _, ok := cfg["agents"]; !ok { + cfg["agents"] = make(map[string]interface{}) + } + agents, ok := cfg["agents"].(map[string]interface{}) + if ok { + if _, ok := agents["defaults"]; !ok { + agents["defaults"] = make(map[string]interface{}) + } + defaults, ok := agents["defaults"].(map[string]interface{}) + if ok { + defaults["model"] = "tui-prefer" + } + } + + tuiModel := map[string]interface{}{ + "model_name": "tui-prefer", + "model": modelID, + "api_key": user.Key, + "api_base": scheme.BaseURL, + } + + modelList := []interface{}{} + if ml, ok := cfg["model_list"].([]interface{}); ok { + modelList = ml + } + + found := false + for i, m := range modelList { + if entry, ok := m.(map[string]interface{}); ok { + if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" { + modelList[i] = tuiModel + found = true + break + } + } + } + if !found { + modelList = append(modelList, tuiModel) + } + cfg["model_list"] = modelList + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(mainConfigPath), 0o700); err != nil { + return err + } + + return os.WriteFile(mainConfigPath, data, 0o600) +} + func (cfg *TUIConfig) CurrentModelLabel() string { cur := cfg.Provider.Current if cur.Model == "" { diff --git a/cmd/picoclaw-launcher-tui/main.go b/cmd/picoclaw-launcher-tui/main.go index 3d7e62b08..057206ab1 100644 --- a/cmd/picoclaw-launcher-tui/main.go +++ b/cmd/picoclaw-launcher-tui/main.go @@ -26,6 +26,10 @@ func main() { } app := ui.New(cfg, configPath) + // Bind model selection hook to sync to main config + app.OnModelSelected = func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) { + _ = tuicfg.SyncSelectedModelToMainConfig(scheme, user, modelID) + } if err := app.Run(); err != nil { fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err) os.Exit(1) diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go index 53d1cf8cd..4978935d9 100644 --- a/cmd/picoclaw-launcher-tui/ui/app.go +++ b/cmd/picoclaw-launcher-tui/ui/app.go @@ -25,6 +25,10 @@ type App struct { headerModelTV *tview.TextView modalOpen map[string]bool + // OnModelSelected is called when a model is selected in the UI. + // Can be nil to disable. + OnModelSelected func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) + modelCache map[string][]modelEntry modelCacheMu sync.RWMutex refreshMu sync.Mutex diff --git a/cmd/picoclaw-launcher-tui/ui/models.go b/cmd/picoclaw-launcher-tui/ui/models.go index 46daaeb3e..1f9484b26 100644 --- a/cmd/picoclaw-launcher-tui/ui/models.go +++ b/cmd/picoclaw-launcher-tui/ui/models.go @@ -116,6 +116,24 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv Model: modelIDs[row], } a.save() + + // Trigger model selected callback if set + if a.OnModelSelected != nil && a.cfg.Model.Type == "provider" { + scheme := a.cfg.Provider.SchemeByName(schemeName) + if scheme == nil { + a.goBack() + return + } + var user tuicfg.User + for _, u := range a.cfg.Provider.Users { + if u.Scheme == schemeName && u.Name == userName { + user = u + break + } + } + a.OnModelSelected(*scheme, user, modelIDs[row]) + } + a.goBack() }) From 7b4d5d4513bc8669710df808995dcf92a5832c19 Mon Sep 17 00:00:00 2001 From: taorye Date: Fri, 20 Mar 2026 17:58:33 +0800 Subject: [PATCH 06/10] feat: add channels management page and integrate into home menu --- cmd/picoclaw-launcher-tui/ui/app.go | 1 + cmd/picoclaw-launcher-tui/ui/channels.go | 194 +++++++++++++++++++++++ cmd/picoclaw-launcher-tui/ui/home.go | 3 + 3 files changed, 198 insertions(+) create mode 100644 cmd/picoclaw-launcher-tui/ui/channels.go diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go index 4978935d9..b410581f9 100644 --- a/cmd/picoclaw-launcher-tui/ui/app.go +++ b/cmd/picoclaw-launcher-tui/ui/app.go @@ -303,6 +303,7 @@ func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tv sbText += menuItem("schemes", "SCHEMES") sbText += menuItem("users", "USERS") sbText += menuItem("models", "MODELS") + sbText += menuItem("channels", "CHANNELS") sidebar.SetText(sbText) diff --git a/cmd/picoclaw-launcher-tui/ui/channels.go b/cmd/picoclaw-launcher-tui/ui/channels.go new file mode 100644 index 000000000..4ba87b617 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/channels.go @@ -0,0 +1,194 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "strconv" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func (a *App) newChannelsPage() tview.Primitive { + list := tview.NewList() + list.SetBorder(true).SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS ").SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff)) + list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0)) + list.SetSecondaryTextColor(tcell.NewHexColor(0x808080)) + list.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510))) + list.SetHighlightFullLine(true) + list.SetBackgroundColor(tcell.NewHexColor(0x050510)) + + rebuild := func() { + sel := list.GetCurrentItem() + list.Clear() + + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + configPath := filepath.Join(home, ".picoclaw", "config.json") + + var cfg map[string]interface{} + if data, err := os.ReadFile(configPath); err == nil { + _ = json.Unmarshal(data, &cfg) + } + + if chRaw, ok := cfg["channels"].(map[string]interface{}); ok { + for name, ch := range chRaw { + chMap, ok := ch.(map[string]interface{}) + enabled := "disabled" + if ok { + if e, ok := chMap["enabled"].(bool); ok && e { + enabled = "enabled" + } + } + list.AddItem(name, fmt.Sprintf("Status: %s", enabled), 0, func() { + a.showChannelEditForm(configPath, name, chMap) + }) + } + } + + if sel >= 0 && sel < list.GetItemCount() { + list.SetCurrentItem(sel) + } + } + rebuild() + + a.pageRefreshFns["channels"] = rebuild + + list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + return a.goBack() + } + return event + }) + + return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ") +} + +func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]interface{}) { + form := tview.NewForm() + form.SetBorder(true).SetTitle(" [::b]EDIT CHANNEL ").SetTitleColor(tcell.NewHexColor(0x39ff14)).SetBorderColor(tcell.NewHexColor(0x00f0ff)) + form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) + form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510)) + form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff)) + form.SetLabelColor(tcell.NewHexColor(0xe0e0e0)) + form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff)) + form.SetButtonTextColor(tcell.NewHexColor(0xffffff)) + + fields := make(map[string]*tview.InputField) + var nameField *tview.InputField + + if channelName == "" { + nameField = tview.NewInputField(). + SetLabel("Channel Name"). + SetText(""). + SetFieldWidth(28) + form.AddFormItem(nameField) + } + + for k, v := range existing { + if reflect.ValueOf(v).Kind() == reflect.Map || reflect.ValueOf(v).Kind() == reflect.Slice { + continue + } + valStr := fmt.Sprintf("%v", v) + field := tview.NewInputField(). + SetLabel(k). + SetText(valStr). + SetFieldWidth(28) + form.AddFormItem(field) + fields[k] = field + } + + form.AddButton("SAVE", func() { + var cfg map[string]interface{} + if data, err := os.ReadFile(configPath); err == nil { + if err := json.Unmarshal(data, &cfg); err != nil { + cfg = make(map[string]interface{}) + } + } else { + cfg = make(map[string]interface{}) + } + + if _, ok := cfg["channels"]; !ok { + cfg["channels"] = make(map[string]interface{}) + } + channels, ok := cfg["channels"].(map[string]interface{}) + if !ok { + channels = make(map[string]interface{}) + cfg["channels"] = channels + } + + finalName := channelName + if channelName == "" { + if nameField == nil || nameField.GetText() == "" { + a.showError("Channel name is required") + return + } + finalName = nameField.GetText() + } + + updated := make(map[string]interface{}) + if existing != nil { + for k, v := range existing { + updated[k] = v + } + } + for k, field := range fields { + val := field.GetText() + if val == "true" { + updated[k] = true + } else if val == "false" { + updated[k] = false + } else if num, err := strconv.Atoi(val); err == nil { + updated[k] = num + } else { + updated[k] = val + } + } + + if channelName != "" && finalName != channelName { + delete(channels, channelName) + } + channels[finalName] = updated + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + a.showError(fmt.Sprintf("Failed to save config: %v", err)) + return + } + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + a.showError(fmt.Sprintf("Failed to create config directory: %v", err)) + return + } + if err := os.WriteFile(configPath, data, 0o600); err != nil { + a.showError(fmt.Sprintf("Failed to write config: %v", err)) + return + } + + a.hideModal("channel-edit") + a.goBack() + }) + + form.AddButton("CANCEL", func() { + a.hideModal("channel-edit") + }) + + form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + a.hideModal("channel-edit") + return nil + } + return event + }) + + a.showModal("channel-edit", centeredForm(form, 4, 20)) +} diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go index 4e952d534..49524acf1 100644 --- a/cmd/picoclaw-launcher-tui/ui/home.go +++ b/cmd/picoclaw-launcher-tui/ui/home.go @@ -25,6 +25,9 @@ func (a *App) newHomePage() tview.Primitive { list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() { a.navigateTo("schemes", a.newSchemesPage()) }) + list.AddItem("CHANNELS: Configure communication channels", "Manage Telegram/Discord/WeChat channels", 'n', func() { + a.navigateTo("channels", a.newChannelsPage()) + }) list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() }) if sel >= 0 && sel < list.GetItemCount() { list.SetCurrentItem(sel) From 02da117199934a15bd5f152c4a063a997a055700 Mon Sep 17 00:00:00 2001 From: taorye Date: Fri, 20 Mar 2026 19:07:06 +0800 Subject: [PATCH 07/10] feat: add gateway management page to TUI and integrate into home menu --- cmd/picoclaw-launcher-tui/ui/app.go | 1 + cmd/picoclaw-launcher-tui/ui/gateway.go | 251 ++++++++++++++++++++++++ cmd/picoclaw-launcher-tui/ui/home.go | 3 + 3 files changed, 255 insertions(+) create mode 100644 cmd/picoclaw-launcher-tui/ui/gateway.go diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go index b410581f9..512277129 100644 --- a/cmd/picoclaw-launcher-tui/ui/app.go +++ b/cmd/picoclaw-launcher-tui/ui/app.go @@ -304,6 +304,7 @@ func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tv sbText += menuItem("users", "USERS") sbText += menuItem("models", "MODELS") sbText += menuItem("channels", "CHANNELS") + sbText += menuItem("gateway", "GATEWAY") sidebar.SetText(sbText) diff --git a/cmd/picoclaw-launcher-tui/ui/gateway.go b/cmd/picoclaw-launcher-tui/ui/gateway.go new file mode 100644 index 000000000..d71f7b488 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/gateway.go @@ -0,0 +1,251 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +const pidFileName = "gateway.pid" + +type gatewayStatus struct { + running bool + pid int +} + +func getPidPath() string { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + return filepath.Join(home, ".picoclaw", pidFileName) +} + +func isProcessRunning(pid int) bool { + if runtime.GOOS == "windows" { + cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid)) + output, err := cmd.Output() + if err != nil { + return false + } + return strings.Contains(string(output), strconv.Itoa(pid)) + } else if runtime.GOOS == "darwin" { + cmd := exec.Command("ps", "aux") + output, err := cmd.Output() + if err != nil { + return false + } + return strings.Contains(string(output), fmt.Sprintf(" %d ", pid)) + } + // Linux + _, err := os.Stat(fmt.Sprintf("/proc/%d", pid)) + return err == nil +} + +func getGatewayStatus() gatewayStatus { + pidPath := getPidPath() + data, err := os.ReadFile(pidPath) + if err != nil { + return gatewayStatus{running: false} + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + return gatewayStatus{running: false} + } + if !isProcessRunning(pid) { + os.Remove(pidPath) + return gatewayStatus{running: false} + } + return gatewayStatus{ + running: true, + pid: pid, + } +} + +func startGateway() error { + status := getGatewayStatus() + if status.running { + return fmt.Errorf("gateway is already running (PID: %d)", status.pid) + } + + pidPath := getPidPath() + var cmd *exec.Cmd + + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1") + } else { + cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 & echo $! > "+pidPath) + } + + err := cmd.Start() + if err != nil { + return err + } + + time.Sleep(1 * time.Second) + + if runtime.GOOS == "windows" { + cmd := exec.Command("wmic", "process", "where", "name='picoclaw.exe' and commandline like '%gateway%'", "get", "processid") + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to get gateway PID: %w", err) + } + lines := strings.Split(string(output), "\n") + for _, line := range lines[1:] { + line = strings.TrimSpace(line) + if line == "" { + continue + } + pid, err := strconv.Atoi(line) + if err == nil { + os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0o600) + break + } + } + } + + status = getGatewayStatus() + if !status.running { + return fmt.Errorf("failed to start gateway") + } + return nil +} + +func stopGateway() error { + status := getGatewayStatus() + if !status.running { + return fmt.Errorf("gateway is not running") + } + + var err error + if runtime.GOOS == "windows" { + err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run() + } else { + err = exec.Command("kill", "-9", strconv.Itoa(status.pid)).Run() + } + if err != nil { + return err + } + + // 多次尝试确认进程已停止 + for i := 0; i < 5; i++ { + if !isProcessRunning(status.pid) { + break + } + time.Sleep(200 * time.Millisecond) + } + + os.Remove(getPidPath()) + return nil +} + +func (a *App) newGatewayPage() tview.Primitive { + flex := tview.NewFlex().SetDirection(tview.FlexRow) + flex.SetBorder(true).SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT ").SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff)) + flex.SetBackgroundColor(tcell.NewHexColor(0x050510)) + + statusTV := tview.NewTextView(). + SetDynamicColors(true). + SetTextAlign(tview.AlignCenter). + SetText("Checking status...") + statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510)) + + var updateStatus func() + + // 使用List作为按钮,保证显示和交互正常 + buttons := tview.NewList() + buttons.SetBackgroundColor(tcell.NewHexColor(0x050510)) + buttons.SetMainTextColor(tcell.ColorWhite) + buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff)) + buttons.SetSelectedTextColor(tcell.ColorBlack) + + buttons.AddItem(" [lime]START[white] ", "", 0, func() { + if !getGatewayStatus().running { + err := startGateway() + if err != nil { + a.showError(err.Error()) + } + updateStatus() + } + }) + buttons.AddItem(" [red]STOP[white] ", "", 0, func() { + if getGatewayStatus().running { + err := stopGateway() + if err != nil { + a.showError(err.Error()) + } + updateStatus() + } + }) + + buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn) + buttonFlex. + AddItem(tview.NewBox(), 0, 1, false). + AddItem(buttons, 20, 1, true). + AddItem(tview.NewBox(), 0, 1, false) + + flex. + AddItem(tview.NewBox(), 0, 1, false). + AddItem(statusTV, 3, 1, false). + AddItem(tview.NewBox(), 0, 1, false). + AddItem(buttonFlex, 4, 1, true). + AddItem(tview.NewBox(), 0, 1, false) + + updateStatus = func() { + status := getGatewayStatus() + if status.running { + statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d", status.pid)) + buttons.SetItemText(0, " [gray]START[white] ", "") + buttons.SetItemText(1, " [red]STOP[white] ", "") + } else { + statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A") + buttons.SetItemText(0, " [lime]START[white] ", "") + buttons.SetItemText(1, " [gray]STOP[white] ", "") + } + } + + updateStatus() + + done := make(chan struct{}) + go func() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + a.tapp.QueueUpdateDraw(updateStatus) + case <-done: + return + } + } + }() + + originalInputCapture := flex.GetInputCapture() + flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + close(done) + return a.goBack() + } + if originalInputCapture != nil { + return originalInputCapture(event) + } + return event + }) + + a.pageRefreshFns["gateway"] = updateStatus + + return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ") +} diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go index 49524acf1..e3563f2bc 100644 --- a/cmd/picoclaw-launcher-tui/ui/home.go +++ b/cmd/picoclaw-launcher-tui/ui/home.go @@ -28,6 +28,9 @@ func (a *App) newHomePage() tview.Primitive { list.AddItem("CHANNELS: Configure communication channels", "Manage Telegram/Discord/WeChat channels", 'n', func() { a.navigateTo("channels", a.newChannelsPage()) }) + list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() { + a.navigateTo("gateway", a.newGatewayPage()) + }) list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() }) if sel >= 0 && sel < list.GetItemCount() { list.SetCurrentItem(sel) From 8c44597c3dda8bda660b9a21e05f464813935a4b Mon Sep 17 00:00:00 2001 From: taorye Date: Fri, 20 Mar 2026 19:16:36 +0800 Subject: [PATCH 08/10] feat: add chat functionality to home page for interactive AI sessions --- cmd/picoclaw-launcher-tui/ui/home.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go index e3563f2bc..6d906eccd 100644 --- a/cmd/picoclaw-launcher-tui/ui/home.go +++ b/cmd/picoclaw-launcher-tui/ui/home.go @@ -6,6 +6,9 @@ package ui import ( + "os" + "os/exec" + "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) @@ -31,6 +34,15 @@ func (a *App) newHomePage() tview.Primitive { list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() { a.navigateTo("gateway", a.newGatewayPage()) }) + list.AddItem("CHAT: Start AI agent chat", "Launch interactive chat session", 'c', func() { + a.tapp.Suspend(func() { + cmd := exec.Command("picoclaw", "agent") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() + }) + }) list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() }) if sel >= 0 && sel < list.GetItemCount() { list.SetCurrentItem(sel) @@ -40,5 +52,5 @@ func (a *App) newHomePage() tview.Primitive { a.pageRefreshFns["home"] = rebuildList - return a.buildShell("home", list, " [#00f0ff]m:[-] configure model [#ff2a2a]q:[-] quit ") + return a.buildShell("home", list, " [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ") } From ed47d5f7c301d2b76c98e8a11f0ac7f659debf3a Mon Sep 17 00:00:00 2001 From: taorye Date: Fri, 20 Mar 2026 19:20:58 +0800 Subject: [PATCH 09/10] feat: add onboarding command execution for non-existent config directory --- cmd/picoclaw-launcher-tui/main.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cmd/picoclaw-launcher-tui/main.go b/cmd/picoclaw-launcher-tui/main.go index 057206ab1..3cb7110c1 100644 --- a/cmd/picoclaw-launcher-tui/main.go +++ b/cmd/picoclaw-launcher-tui/main.go @@ -8,6 +8,8 @@ package main import ( "fmt" "os" + "os/exec" + "path/filepath" tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui" @@ -19,6 +21,15 @@ func main() { configPath = os.Args[1] } + configDir := filepath.Dir(configPath) + if _, err := os.Stat(configDir); os.IsNotExist(err) { + cmd := exec.Command("picoclaw", "onboard") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() + } + cfg, err := tuicfg.Load(configPath) if err != nil { fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err) From 955d6e70f19daed49245a12c21a1516692d180ce Mon Sep 17 00:00:00 2001 From: taorye Date: Fri, 20 Mar 2026 19:41:59 +0800 Subject: [PATCH 10/10] refactor: update interface types to use 'any' and improve code formatting --- cmd/picoclaw-launcher-tui/config/config.go | 27 ++++++++-------- cmd/picoclaw-launcher-tui/ui/app.go | 2 +- cmd/picoclaw-launcher-tui/ui/channels.go | 36 +++++++++++++--------- cmd/picoclaw-launcher-tui/ui/gateway.go | 14 +++++++-- cmd/picoclaw-launcher-tui/ui/home.go | 26 ++++++++++++---- cmd/picoclaw-launcher-tui/ui/models.go | 15 +++++++-- cmd/picoclaw-launcher-tui/ui/schemes.go | 21 ++++++++++--- cmd/picoclaw-launcher-tui/ui/users.go | 21 ++++++++++--- 8 files changed, 115 insertions(+), 47 deletions(-) diff --git a/cmd/picoclaw-launcher-tui/config/config.go b/cmd/picoclaw-launcher-tui/config/config.go index 64d479285..227b9fa3d 100644 --- a/cmd/picoclaw-launcher-tui/config/config.go +++ b/cmd/picoclaw-launcher-tui/config/config.go @@ -14,6 +14,7 @@ import ( "path/filepath" "github.com/BurntSushi/toml" + "github.com/sipeed/picoclaw/pkg/fileutil" ) @@ -160,44 +161,44 @@ func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) err } mainConfigPath := filepath.Join(home, ".picoclaw", "config.json") - var cfg map[string]interface{} - if data, err := os.ReadFile(mainConfigPath); err == nil { - if err := json.Unmarshal(data, &cfg); err != nil { - cfg = make(map[string]interface{}) + var cfg map[string]any + if data, readErr := os.ReadFile(mainConfigPath); readErr == nil { + if unmarshalErr := json.Unmarshal(data, &cfg); unmarshalErr != nil { + cfg = make(map[string]any) } } else { - cfg = make(map[string]interface{}) + cfg = make(map[string]any) } if _, ok := cfg["agents"]; !ok { - cfg["agents"] = make(map[string]interface{}) + cfg["agents"] = make(map[string]any) } - agents, ok := cfg["agents"].(map[string]interface{}) + agents, ok := cfg["agents"].(map[string]any) if ok { if _, ok := agents["defaults"]; !ok { - agents["defaults"] = make(map[string]interface{}) + agents["defaults"] = make(map[string]any) } - defaults, ok := agents["defaults"].(map[string]interface{}) + defaults, ok := agents["defaults"].(map[string]any) if ok { defaults["model"] = "tui-prefer" } } - tuiModel := map[string]interface{}{ + tuiModel := map[string]any{ "model_name": "tui-prefer", "model": modelID, "api_key": user.Key, "api_base": scheme.BaseURL, } - modelList := []interface{}{} - if ml, ok := cfg["model_list"].([]interface{}); ok { + modelList := []any{} + if ml, ok := cfg["model_list"].([]any); ok { modelList = ml } found := false for i, m := range modelList { - if entry, ok := m.(map[string]interface{}); ok { + if entry, ok := m.(map[string]any); ok { if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" { modelList[i] = tuiModel found = true diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go index 512277129..a65693b01 100644 --- a/cmd/picoclaw-launcher-tui/ui/app.go +++ b/cmd/picoclaw-launcher-tui/ui/app.go @@ -11,6 +11,7 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" ) @@ -79,7 +80,6 @@ func (a *App) refreshModelCache(onDone func()) { continue } wg.Add(1) - u := u bURL := baseURL go func() { defer wg.Done() diff --git a/cmd/picoclaw-launcher-tui/ui/channels.go b/cmd/picoclaw-launcher-tui/ui/channels.go index 4ba87b617..c976f1fcd 100644 --- a/cmd/picoclaw-launcher-tui/ui/channels.go +++ b/cmd/picoclaw-launcher-tui/ui/channels.go @@ -19,10 +19,15 @@ import ( func (a *App) newChannelsPage() tview.Primitive { list := tview.NewList() - list.SetBorder(true).SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS ").SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff)) + list.SetBorder(true). + SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS "). + SetTitleColor(tcell.NewHexColor(0x00f0ff)). + SetBorderColor(tcell.NewHexColor(0x00f0ff)) list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0)) list.SetSecondaryTextColor(tcell.NewHexColor(0x808080)) - list.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510))) + list.SetSelectedStyle( + tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)), + ) list.SetHighlightFullLine(true) list.SetBackgroundColor(tcell.NewHexColor(0x050510)) @@ -36,14 +41,14 @@ func (a *App) newChannelsPage() tview.Primitive { } configPath := filepath.Join(home, ".picoclaw", "config.json") - var cfg map[string]interface{} + var cfg map[string]any if data, err := os.ReadFile(configPath); err == nil { _ = json.Unmarshal(data, &cfg) } - if chRaw, ok := cfg["channels"].(map[string]interface{}); ok { + if chRaw, ok := cfg["channels"].(map[string]any); ok { for name, ch := range chRaw { - chMap, ok := ch.(map[string]interface{}) + chMap, ok := ch.(map[string]any) enabled := "disabled" if ok { if e, ok := chMap["enabled"].(bool); ok && e { @@ -74,9 +79,12 @@ func (a *App) newChannelsPage() tview.Primitive { return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ") } -func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]interface{}) { +func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]any) { form := tview.NewForm() - form.SetBorder(true).SetTitle(" [::b]EDIT CHANNEL ").SetTitleColor(tcell.NewHexColor(0x39ff14)).SetBorderColor(tcell.NewHexColor(0x00f0ff)) + form.SetBorder(true). + SetTitle(" [::b]EDIT CHANNEL "). + SetTitleColor(tcell.NewHexColor(0x39ff14)). + SetBorderColor(tcell.NewHexColor(0x00f0ff)) form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510)) form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff)) @@ -109,21 +117,21 @@ func (a *App) showChannelEditForm(configPath, channelName string, existing map[s } form.AddButton("SAVE", func() { - var cfg map[string]interface{} + var cfg map[string]any if data, err := os.ReadFile(configPath); err == nil { if err := json.Unmarshal(data, &cfg); err != nil { - cfg = make(map[string]interface{}) + cfg = make(map[string]any) } } else { - cfg = make(map[string]interface{}) + cfg = make(map[string]any) } if _, ok := cfg["channels"]; !ok { - cfg["channels"] = make(map[string]interface{}) + cfg["channels"] = make(map[string]any) } - channels, ok := cfg["channels"].(map[string]interface{}) + channels, ok := cfg["channels"].(map[string]any) if !ok { - channels = make(map[string]interface{}) + channels = make(map[string]any) cfg["channels"] = channels } @@ -136,7 +144,7 @@ func (a *App) showChannelEditForm(configPath, channelName string, existing map[s finalName = nameField.GetText() } - updated := make(map[string]interface{}) + updated := make(map[string]any) if existing != nil { for k, v := range existing { updated[k] = v diff --git a/cmd/picoclaw-launcher-tui/ui/gateway.go b/cmd/picoclaw-launcher-tui/ui/gateway.go index d71f7b488..1138c12db 100644 --- a/cmd/picoclaw-launcher-tui/ui/gateway.go +++ b/cmd/picoclaw-launcher-tui/ui/gateway.go @@ -98,7 +98,14 @@ func startGateway() error { time.Sleep(1 * time.Second) if runtime.GOOS == "windows" { - cmd := exec.Command("wmic", "process", "where", "name='picoclaw.exe' and commandline like '%gateway%'", "get", "processid") + cmd := exec.Command( + "wmic", + "process", + "where", + "name='picoclaw.exe' and commandline like '%gateway%'", + "get", + "processid", + ) output, err := cmd.Output() if err != nil { return fmt.Errorf("failed to get gateway PID: %w", err) @@ -154,7 +161,10 @@ func stopGateway() error { func (a *App) newGatewayPage() tview.Primitive { flex := tview.NewFlex().SetDirection(tview.FlexRow) - flex.SetBorder(true).SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT ").SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff)) + flex.SetBorder(true). + SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT "). + SetTitleColor(tcell.NewHexColor(0x00f0ff)). + SetBorderColor(tcell.NewHexColor(0x00f0ff)) flex.SetBackgroundColor(tcell.NewHexColor(0x050510)) statusTV := tview.NewTextView(). diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go index 6d906eccd..74a7769cf 100644 --- a/cmd/picoclaw-launcher-tui/ui/home.go +++ b/cmd/picoclaw-launcher-tui/ui/home.go @@ -15,10 +15,15 @@ import ( func (a *App) newHomePage() tview.Primitive { list := tview.NewList() - list.SetBorder(true).SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION ").SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff)) + list.SetBorder(true). + SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION "). + SetTitleColor(tcell.NewHexColor(0x00f0ff)). + SetBorderColor(tcell.NewHexColor(0x00f0ff)) list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0)) list.SetSecondaryTextColor(tcell.NewHexColor(0x808080)) - list.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510))) + list.SetSelectedStyle( + tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)), + ) list.SetHighlightFullLine(true) list.SetBackgroundColor(tcell.NewHexColor(0x050510)) @@ -28,9 +33,14 @@ func (a *App) newHomePage() tview.Primitive { list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() { a.navigateTo("schemes", a.newSchemesPage()) }) - list.AddItem("CHANNELS: Configure communication channels", "Manage Telegram/Discord/WeChat channels", 'n', func() { - a.navigateTo("channels", a.newChannelsPage()) - }) + list.AddItem( + "CHANNELS: Configure communication channels", + "Manage Telegram/Discord/WeChat channels", + 'n', + func() { + a.navigateTo("channels", a.newChannelsPage()) + }, + ) list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() { a.navigateTo("gateway", a.newGatewayPage()) }) @@ -52,5 +62,9 @@ func (a *App) newHomePage() tview.Primitive { a.pageRefreshFns["home"] = rebuildList - return a.buildShell("home", list, " [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ") + return a.buildShell( + "home", + list, + " [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ", + ) } diff --git a/cmd/picoclaw-launcher-tui/ui/models.go b/cmd/picoclaw-launcher-tui/ui/models.go index 1f9484b26..20e5f0182 100644 --- a/cmd/picoclaw-launcher-tui/ui/models.go +++ b/cmd/picoclaw-launcher-tui/ui/models.go @@ -15,6 +15,7 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" ) @@ -33,8 +34,13 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv SetBorders(false). SetSelectable(true, false). SetFixed(0, 0) - table.SetBorder(true).SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)).SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff)) - table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff))) + table.SetBorder(true). + SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)). + SetTitleColor(tcell.NewHexColor(0x00f0ff)). + SetBorderColor(tcell.NewHexColor(0x00f0ff)) + table.SetSelectedStyle( + tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)), + ) table.SetBackgroundColor(tcell.NewHexColor(0x050510)) var modelIDs []string @@ -187,5 +193,8 @@ func fetchModels(baseURL, apiKey string) ([]modelEntry, error) { return arr, nil } - return nil, fmt.Errorf("decode response: unrecognised shape: %s", strings.TrimSpace(string(body[:min(len(body), 256)]))) + return nil, fmt.Errorf( + "decode response: unrecognized shape: %s", + strings.TrimSpace(string(body[:min(len(body), 256)])), + ) } diff --git a/cmd/picoclaw-launcher-tui/ui/schemes.go b/cmd/picoclaw-launcher-tui/ui/schemes.go index 70375eccc..e38d7fa86 100644 --- a/cmd/picoclaw-launcher-tui/ui/schemes.go +++ b/cmd/picoclaw-launcher-tui/ui/schemes.go @@ -10,6 +10,7 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" ) @@ -17,8 +18,13 @@ func (a *App) newSchemesPage() tview.Primitive { table := tview.NewTable(). SetBorders(false). SetSelectable(true, false) - table.SetBorder(true).SetTitle(" [#00f0ff::b] PROVIDER SCHEMES ").SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff)) - table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff))) + table.SetBorder(true). + SetTitle(" [#00f0ff::b] PROVIDER SCHEMES "). + SetTitleColor(tcell.NewHexColor(0x00f0ff)). + SetBorderColor(tcell.NewHexColor(0x00f0ff)) + table.SetSelectedStyle( + tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)), + ) table.SetBackgroundColor(tcell.NewHexColor(0x050510)) rowToIdx := func(row int) int { return row / 2 } @@ -165,7 +171,11 @@ func (a *App) newSchemesPage() tview.Primitive { return event }) - return a.buildShell("schemes", table, " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ") + return a.buildShell( + "schemes", + table, + " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ", + ) } func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) { @@ -220,7 +230,10 @@ func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme) a.hideModal("scheme-form") }) - form.SetBorder(true).SetTitle(" [::b]" + title + " ").SetTitleColor(tcell.NewHexColor(0x39ff14)).SetBorderColor(tcell.NewHexColor(0x00f0ff)) + form.SetBorder(true). + SetTitle(" [::b]" + title + " "). + SetTitleColor(tcell.NewHexColor(0x39ff14)). + SetBorderColor(tcell.NewHexColor(0x00f0ff)) form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510)) form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff)) diff --git a/cmd/picoclaw-launcher-tui/ui/users.go b/cmd/picoclaw-launcher-tui/ui/users.go index 4a877d3c7..b00fc8982 100644 --- a/cmd/picoclaw-launcher-tui/ui/users.go +++ b/cmd/picoclaw-launcher-tui/ui/users.go @@ -10,6 +10,7 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" ) @@ -17,8 +18,13 @@ func (a *App) newUsersPage(schemeName string) tview.Primitive { table := tview.NewTable(). SetBorders(false). SetSelectable(true, false) - table.SetBorder(true).SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)).SetTitleColor(tcell.NewHexColor(0x00f0ff)).SetBorderColor(tcell.NewHexColor(0x00f0ff)) - table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff))) + table.SetBorder(true). + SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)). + SetTitleColor(tcell.NewHexColor(0x00f0ff)). + SetBorderColor(tcell.NewHexColor(0x00f0ff)) + table.SetSelectedStyle( + tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)), + ) table.SetBackgroundColor(tcell.NewHexColor(0x050510)) visibleUsers := func() []tuicfg.User { @@ -179,7 +185,11 @@ func (a *App) newUsersPage(schemeName string) tview.Primitive { return event }) - return a.buildShell("users", table, " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ") + return a.buildShell( + "users", + table, + " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ", + ) } func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) { @@ -229,7 +239,10 @@ func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func a.hideModal("user-form") }) - form.SetBorder(true).SetTitle(" [::b]" + title + " ").SetTitleColor(tcell.NewHexColor(0x39ff14)).SetBorderColor(tcell.NewHexColor(0x00f0ff)) + form.SetBorder(true). + SetTitle(" [::b]" + title + " "). + SetTitleColor(tcell.NewHexColor(0x39ff14)). + SetBorderColor(tcell.NewHexColor(0x00f0ff)) form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510)) form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))