mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(tui): Add configurable Launcher and Gateway process management (#909)
- Implement POSIX-specific gateway process management in gateway_posix.go - Implement Windows-specific gateway process management in gateway_windows.go - Create a menu system in menu.go for user interaction - Develop model management functionality in model.go, including adding, deleting, and testing models - Introduce a style configuration in style.go for consistent UI appearance - Set up the main application entry point in main.go - Update go.mod and go.sum to include necessary dependencies for tcell and tview
This commit is contained in:
@@ -67,6 +67,33 @@ builds:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
|
||||
- id: picoclaw-launcher-tui
|
||||
binary: picoclaw-launcher-tui
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
tags:
|
||||
- stdjson
|
||||
ldflags:
|
||||
- -s -w
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
- freebsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
- loong64
|
||||
- arm
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
main: ./cmd/picoclaw-launcher-tui
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
|
||||
dockers_v2:
|
||||
- id: picoclaw
|
||||
dockerfile: docker/Dockerfile.goreleaser
|
||||
@@ -105,6 +132,7 @@ nfpms:
|
||||
builds:
|
||||
- picoclaw
|
||||
- picoclaw-launcher
|
||||
- picoclaw-launcher-tui
|
||||
package_name: picoclaw
|
||||
file_name_template: >-
|
||||
{{ .PackageName }}_
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"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)
|
||||
|
||||
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("Config Menu", nil)
|
||||
refreshMainMenu(menu, s)
|
||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Key() {
|
||||
case tcell.KeyEsc:
|
||||
s.requestExit()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'q' {
|
||||
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 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()
|
||||
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(selectedModel),
|
||||
Action: func() {
|
||||
s.push("model", s.modelMenu())
|
||||
},
|
||||
MainColor: func() *tcell.Color {
|
||||
if modelReady {
|
||||
return nil
|
||||
}
|
||||
color := tcell.ColorGray
|
||||
return &color
|
||||
}(),
|
||||
},
|
||||
{
|
||||
Label: rootChannelLabel(channelReady),
|
||||
Description: rootChannelDescription(channelReady),
|
||||
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 (no model selected)"
|
||||
}
|
||||
return "Model (" + selected + ")"
|
||||
}
|
||||
|
||||
func rootModelDescription(selected string) string {
|
||||
if selected == "" {
|
||||
return "no model selected"
|
||||
}
|
||||
return "selected"
|
||||
}
|
||||
|
||||
func rootChannelLabel(valid bool) string {
|
||||
if !valid {
|
||||
return "Channel (no channel enabled)"
|
||||
}
|
||||
return "Channel"
|
||||
}
|
||||
|
||||
func rootChannelDescription(valid bool) string {
|
||||
if !valid {
|
||||
return "no channel enabled"
|
||||
}
|
||||
return "enabled"
|
||||
}
|
||||
|
||||
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.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)
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func (s *appState) channelMenu() tview.Primitive {
|
||||
items := []MenuItem{
|
||||
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||
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(
|
||||
"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()) },
|
||||
),
|
||||
}
|
||||
|
||||
menu := NewMenu("Channels", items)
|
||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEsc {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'q' {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
return menu
|
||||
}
|
||||
|
||||
func refreshChannelMenuFromState(menu *Menu, s *appState) {
|
||||
items := []MenuItem{
|
||||
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||
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(
|
||||
"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()) },
|
||||
),
|
||||
}
|
||||
menu.applyItems(items)
|
||||
}
|
||||
|
||||
func (s *appState) telegramForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.Telegram
|
||||
form := baseChannelForm("Telegram", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
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)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) discordForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.Discord
|
||||
form := baseChannelForm("Discord", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
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
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) qqForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.QQ
|
||||
form := baseChannelForm("QQ", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
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("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) maixcamForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.MaixCam
|
||||
form := baseChannelForm("MaixCam", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
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 })
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) whatsappForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.WhatsApp
|
||||
form := baseChannelForm("WhatsApp", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) {
|
||||
cfg.BridgeURL = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) feishuForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.Feishu
|
||||
form := baseChannelForm("Feishu", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
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)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) dingtalkForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.DingTalk
|
||||
form := baseChannelForm("DingTalk", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
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)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) slackForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.Slack
|
||||
form := baseChannelForm("Slack", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
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)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) lineForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.LINE
|
||||
form := baseChannelForm("LINE", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
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)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) onebotForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.OneBot
|
||||
form := baseChannelForm("OneBot", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
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)
|
||||
},
|
||||
)
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) wecomForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.WeCom
|
||||
form := baseChannelForm("WeCom", cfg.Enabled, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
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)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
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, func(v bool) {
|
||||
cfg.Enabled = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
})
|
||||
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)
|
||||
})
|
||||
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||
cfg.AllowFrom = splitCSV(text)
|
||||
})
|
||||
addIntField(
|
||||
form,
|
||||
"Reply Timeout",
|
||||
cfg.ReplyTimeout,
|
||||
func(value int) { cfg.ReplyTimeout = value },
|
||||
)
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//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()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//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()
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
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, 2+len(s.config.ModelList))
|
||||
items = append(items,
|
||||
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||
MenuItem{
|
||||
Label: "Add model",
|
||||
Description: "Append a new model entry",
|
||||
Action: func() {
|
||||
s.addModel(
|
||||
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
||||
)
|
||||
s.push(
|
||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||
s.modelForm(len(s.config.ModelList)-1),
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
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))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
menu := NewMenu("Models", items)
|
||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEsc {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'q' {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == ' ' {
|
||||
row, _ := menu.GetSelection()
|
||||
if row > 0 && row <= len(s.config.ModelList) {
|
||||
model := s.config.ModelList[row-1]
|
||||
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))
|
||||
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
||||
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
||||
|
||||
addInput(form, "Model Name", model.ModelName, func(value string) {
|
||||
model.ModelName = value
|
||||
s.dirty = true
|
||||
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() {
|
||||
s.deleteModel(index)
|
||||
})
|
||||
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 + 1
|
||||
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, 2+len(s.config.ModelList))
|
||||
items = append(items,
|
||||
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||
MenuItem{
|
||||
Label: "Add model",
|
||||
Description: "Append a new model entry",
|
||||
Action: func() {
|
||||
s.addModel(
|
||||
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
||||
)
|
||||
s.push(
|
||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||
s.modelForm(len(s.config.ModelList)-1),
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
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))
|
||||
},
|
||||
})
|
||||
}
|
||||
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) 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, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
s.showMessage(
|
||||
"Test failed",
|
||||
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
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(
|
||||
"[::b][#84aaff]██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" +
|
||||
"[#84aaff]██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" +
|
||||
"[#84aaff]██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" +
|
||||
"[#84aaff]██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" +
|
||||
"[#84aaff]██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
||||
"[#84aaff]╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝",
|
||||
)
|
||||
text.SetBorder(false)
|
||||
return text
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -33,13 +33,18 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/gdamore/tcell/v2 v2.13.8 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/tview v0.42.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rs/zerolog v1.34.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||
|
||||
@@ -50,6 +50,10 @@ github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
|
||||
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
||||
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
|
||||
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||
@@ -113,6 +117,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
@@ -148,6 +154,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
|
||||
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
|
||||
Reference in New Issue
Block a user