Remove UI components and gateway management for picoclaw-launcher-tui
- Deleted channel management UI from channel.go, including all associated forms and menu items. - Removed platform-specific gateway process management from gateway_posix.go and gateway_windows.go. - Eliminated menu structure and item management from menu.go. - Removed model management and configuration handling from model.go. - Deleted style definitions and application logic from style.go. - Cleared main entry point in main.go.
This commit is contained in:
parent
fe87376d6a
commit
998b456b65
9 changed files with 0 additions and 1577 deletions
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if err := ui.Run(); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue