diff --git a/cmd/picoclaw-launcher-tui/config/config.go b/cmd/picoclaw-launcher-tui/config/config.go new file mode 100644 index 000000000..227b9fa3d --- /dev/null +++ b/cmd/picoclaw-launcher-tui/config/config.go @@ -0,0 +1,236 @@ +// 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" + "encoding/json" + "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 { + 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 { + 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 +} + +// 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]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]any) + } + + if _, ok := cfg["agents"]; !ok { + cfg["agents"] = make(map[string]any) + } + agents, ok := cfg["agents"].(map[string]any) + if ok { + if _, ok := agents["defaults"]; !ok { + agents["defaults"] = make(map[string]any) + } + defaults, ok := agents["defaults"].(map[string]any) + if ok { + defaults["model"] = "tui-prefer" + } + } + + tuiModel := map[string]any{ + "model_name": "tui-prefer", + "model": modelID, + "api_key": user.Key, + "api_base": scheme.BaseURL, + } + + 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]any); 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 == "" { + return "(not configured)" + } + label := cur.Scheme + if label != "" { + label += " / " + } + return label + cur.Model +} 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 index 0e8cce415..3cb7110c1 100644 --- a/cmd/picoclaw-launcher-tui/main.go +++ b/cmd/picoclaw-launcher-tui/main.go @@ -1,15 +1,48 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + package main import ( "fmt" "os" + "os/exec" + "path/filepath" - "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui" + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" + "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui" ) func main() { - if err := ui.Run(); err != nil { - fmt.Fprintln(os.Stderr, err) + configPath := tuicfg.DefaultConfigPath() + if len(os.Args) > 1 { + 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) + os.Exit(1) + } + + 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 new file mode 100644 index 000000000..a65693b01 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/app.go @@ -0,0 +1,325 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "fmt" + "sync" + + "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 + pageRefreshFns map[string]func() + 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 +} + +// 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) + 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 { + // 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(), + 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 event + } + 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.RemovePage(name) + 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 + } + 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 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() { + if err := tuicfg.Save(a.configPath, a.cfg); err != nil { + a.showError("save failed: " + err.Error()) + } +} + +func (a *App) showError(msg string) { + modal := tview.NewModal(). + SetText(" [red::b]ERROR[-::-]\n\n" + msg). + AddButtons([]string{"OK"}). + SetDoneFunc(func(_ int, _ string) { + a.hideModal("error") + }) + // 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(" [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") + if buttonLabel == "Delete" { + onConfirm() + } + }) + // 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) +} + +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). + SetDynamicColors(true). + SetTextAlign(tview.AlignCenter). + SetTextColor(tcell.NewHexColor(0x00f0ff)) // Neon Cyan + tv.SetBackgroundColor(tcell.NewHexColor(0x2a2a40)) // Darker Indigo + 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.NewHexColor(0x39ff14)). // Neon Lime + SetDynamicColors(true). + SetBackgroundColor(tcell.NewHexColor(0x050510)) + } + modelTV = a.headerModelTV + modelTV.SetText("MODEL: " + a.cfg.CurrentModelLabel() + " ") + } else { + modelTV = tview.NewTextView() + modelTV.SetBackgroundColor(tcell.NewHexColor(0x050510)) + } + + headerLeft := tview.NewTextView(). + SetText(" [#ff00ff::b]///[#00f0ff] PICOCLAW LAUNCHER [#ff00ff]///"). + SetDynamicColors(true). + SetBackgroundColor(tcell.NewHexColor(0x050510)) + + header := tview.NewFlex(). + AddItem(headerLeft, 0, 1, false). + AddItem(modelTV, 0, 1, false) + + sidebar := tview.NewTextView(). + SetDynamicColors(true). + SetWrap(false) + sidebar.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo + + // Cyberpunk Sidebar Styling + activePrefix := "[#39ff14::b]>> " // Neon Lime arrow + activeSuffix := "[-]" + inactivePrefix := "[#808080] " + inactiveSuffix := "[-]" + + 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") + sbText += menuItem("channels", "CHANNELS") + sbText += menuItem("gateway", "GATEWAY") + + sidebar.SetText(sbText) + + footer := hintBar(hint) + + grid := tview.NewGrid(). + SetRows(1, 0, 1). + 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/channels.go b/cmd/picoclaw-launcher-tui/ui/channels.go new file mode 100644 index 000000000..c976f1fcd --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/channels.go @@ -0,0 +1,202 @@ +// 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]any + if data, err := os.ReadFile(configPath); err == nil { + _ = json.Unmarshal(data, &cfg) + } + + if chRaw, ok := cfg["channels"].(map[string]any); ok { + for name, ch := range chRaw { + chMap, ok := ch.(map[string]any) + 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]any) { + 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]any + if data, err := os.ReadFile(configPath); err == nil { + if err := json.Unmarshal(data, &cfg); err != nil { + cfg = make(map[string]any) + } + } else { + cfg = make(map[string]any) + } + + if _, ok := cfg["channels"]; !ok { + cfg["channels"] = make(map[string]any) + } + channels, ok := cfg["channels"].(map[string]any) + if !ok { + channels = make(map[string]any) + 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]any) + 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/gateway.go b/cmd/picoclaw-launcher-tui/ui/gateway.go new file mode 100644 index 000000000..1138c12db --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/gateway.go @@ -0,0 +1,261 @@ +// 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 new file mode 100644 index 000000000..74a7769cf --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/home.go @@ -0,0 +1,70 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "os" + "os/exec" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +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.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(), "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("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) + } + } + rebuildList() + + a.pageRefreshFns["home"] = rebuildList + + 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 new file mode 100644 index 000000000..20e5f0182 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/models.go @@ -0,0 +1,200 @@ +// 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(" [#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("[#ffff00]FETCHING MODELS...[-]") + status.SetBackgroundColor(tcell.NewHexColor(0x050510)) + + flex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(status, 1, 0, false). + AddItem(table, 0, 1, false) + + apiKey := a.resolveKey(schemeName, userName) + + go func() { + 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("[#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("[#ff2a2a]NO MODELS RETURNED[-]") + table.SetCell(0, 0, tview.NewTableCell(" (no models available)")) + a.tapp.SetFocus(table) + return + } + + 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.NewHexColor(0x808080)). + SetSelectable(false), + ) + table.SetCell(i, 1, + tview.NewTableCell(" "+m.ID). + SetAlign(tview.AlignLeft). + SetExpansion(1). + SetTextColor(tcell.NewHexColor(0xe0e0e0)), + ) + } + 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() + + // 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() + }) + + return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ") +} + +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))) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + 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: 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 new file mode 100644 index 000000000..e38d7fa86 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/schemes.go @@ -0,0 +1,252 @@ +// 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 { + 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.SetBackgroundColor(tcell.NewHexColor(0x050510)) + + 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() { + 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.NewHexColor(0xe0e0e0)). + 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(" [#808080](%d/%d) %s", m, n, s.BaseURL)). + SetTextColor(tcell.NewHexColor(0x808080)). + SetExpansion(1). + SetSelectable(false), + ) + table.SetCell(detailRow, 1, + tview.NewTableCell("[#00f0ff]"+s.Type+" "). + SetAlign(tview.AlignRight). + SetSelectable(false), + ) + } + 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() + + 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() + a.refreshModelCache(rebuild) + }) + return nil + case 'e': + if idx < 0 || idx >= len(schemes) { + return nil + } + origName := schemes[idx].Name + orig := schemes[idx] + a.showSchemeForm(&orig, func(s tuicfg.Scheme) { + current := a.cfg.Provider.Schemes + for i, sc := range current { + if sc.Name == origName { + a.cfg.Provider.Schemes[i] = s + break + } + } + a.save() + 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': + if idx < 0 || idx >= len(schemes) { + return nil + } + name := schemes[idx].Name + a.confirmDelete(fmt.Sprintf("scheme %q", name), func() { + 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() + a.refreshModelCache(rebuild) + }) + return nil + } + return event + }) + + 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 " + + 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() + + form. + 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() { + 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") + }) + + 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") + return nil + } + return event + }) + + 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 new file mode 100644 index 000000000..b00fc8982 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/users.go @@ -0,0 +1,261 @@ +// 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 { + 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.SetBackgroundColor(tcell.NewHexColor(0x050510)) + + visibleUsers := func() []tuicfg.User { + var out []tuicfg.User + for _, u := range a.cfg.Provider.Users { + if u.Scheme == schemeName { + 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() { + 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.NewHexColor(0xe0e0e0)). + 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(" [#39ff14]%d models available[-]", len(models)) + } else { + detailText = " [#ff2a2a]Inactive / No Access[-]" + } + table.SetCell(detailRow, 0, + tview.NewTableCell(detailText). + SetTextColor(tcell.NewHexColor(0x808080)). + SetExpansion(1). + SetSelectable(false), + ) + table.SetCell(detailRow, 1, + tview.NewTableCell("[#00f0ff]"+u.Type+" "). + SetAlign(tview.AlignRight). + SetSelectable(false), + ) + } + 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() + + 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() + a.refreshModelCache(rebuild) + }) + return nil + case 'e': + if visIdx < 0 || visIdx >= len(users) { + return nil + } + 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() + a.refreshModelCache(func() { + rebuild() + for i, usr := range visibleUsers() { + if usr.Name == u.Name { + table.Select(i*2, 0) + break + } + } + }) + }) + return nil + case 'd': + if visIdx < 0 || visIdx >= len(users) { + return nil + } + uName := users[visIdx].Name + a.confirmDelete(fmt.Sprintf("user %q", uName), func() { + cfgIdx := findUserGlobalIdx(uName) + if cfgIdx < 0 { + return + } + all := a.cfg.Provider.Users + a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...) + a.save() + a.refreshModelCache(rebuild) + }) + return nil + } + return event + }) + + 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 " + + 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, 20, nil, func(text string) { name = text }). + AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }). + AddPasswordField("Key", key, 28, '*', 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(" [::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") + return nil + } + return event + }) + + a.showModal("user-form", centeredForm(form, 4, 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=