From 5a199ec9937bcc1d0e5172b1b9f4869de328a5ce Mon Sep 17 00:00:00 2001 From: taorye Date: Fri, 20 Mar 2026 11:54:58 +0800 Subject: [PATCH] feat: implement TUI configuration and user management for picoclaw-launcher-tui --- cmd/picoclaw-launcher-tui/config/config.go | 159 ++++++++++++++++++++ cmd/picoclaw-launcher-tui/main.go | 33 +++++ cmd/picoclaw-launcher-tui/ui/app.go | 123 ++++++++++++++++ cmd/picoclaw-launcher-tui/ui/home.go | 43 ++++++ cmd/picoclaw-launcher-tui/ui/models.go | 143 ++++++++++++++++++ cmd/picoclaw-launcher-tui/ui/schemes.go | 147 +++++++++++++++++++ cmd/picoclaw-launcher-tui/ui/users.go | 161 +++++++++++++++++++++ go.mod | 1 + go.sum | 2 + 9 files changed, 812 insertions(+) create mode 100644 cmd/picoclaw-launcher-tui/config/config.go create mode 100644 cmd/picoclaw-launcher-tui/main.go create mode 100644 cmd/picoclaw-launcher-tui/ui/app.go create mode 100644 cmd/picoclaw-launcher-tui/ui/home.go create mode 100644 cmd/picoclaw-launcher-tui/ui/models.go create mode 100644 cmd/picoclaw-launcher-tui/ui/schemes.go create mode 100644 cmd/picoclaw-launcher-tui/ui/users.go diff --git a/cmd/picoclaw-launcher-tui/config/config.go b/cmd/picoclaw-launcher-tui/config/config.go new file mode 100644 index 000000000..15c81f90a --- /dev/null +++ b/cmd/picoclaw-launcher-tui/config/config.go @@ -0,0 +1,159 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +// Package config provides types and I/O for ~/.picoclaw/tui.toml. +package config + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" + "github.com/sipeed/picoclaw/pkg/fileutil" +) + +// DefaultConfigPath returns the default path to the tui.toml config file. +func DefaultConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + return filepath.Join(home, ".picoclaw", "tui.toml") +} + +// TUIConfig is the top-level structure of ~/.picoclaw/tui.toml. +type TUIConfig struct { + Version string `toml:"version"` + Model Model `toml:"model"` + Provider Provider `toml:"provider"` +} + +type Model struct { + Type string `toml:"type"` // "provider" (default) | "manual" +} + +type Provider struct { + Schemes []Scheme `toml:"schemes"` + Users []User `toml:"users"` + Current ProviderCurrent `toml:"current"` +} + +type Scheme struct { + Name string `toml:"name"` // unique key + BaseURL string `toml:"baseURL"` // required + Type string `toml:"type"` // "openai-compatible" (default) | "anthropic" +} + +type User struct { + Name string `toml:"name"` + Scheme string `toml:"scheme"` // references Scheme.Name; (Name+Scheme) is unique + Type string `toml:"type"` // "key" (default) | "OAuth" + Key string `toml:"key"` +} + +type ProviderCurrent struct { + Scheme string `toml:"scheme"` // references Scheme.Name + User string `toml:"user"` // references User.Name where User.Scheme == Scheme + Model string `toml:"model"` // from GET /models +} + +// DefaultConfig returns a minimal valid TUIConfig. +func DefaultConfig() *TUIConfig { + return &TUIConfig{ + Version: "1.0", + Model: Model{Type: "provider"}, + Provider: Provider{ + Schemes: []Scheme{}, + Users: []User{}, + Current: ProviderCurrent{}, + }, + } +} + +// Load reads the TUI config from path. Returns a default config if the file does not exist. +func Load(path string) (*TUIConfig, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return DefaultConfig(), nil + } + if err != nil { + return nil, fmt.Errorf("failed to read config file %q: %w", path, err) + } + + cfg := DefaultConfig() + if _, err := toml.Decode(string(data), cfg); err != nil { + return nil, fmt.Errorf("failed to parse config file %q: %w", path, err) + } + + applyDefaults(cfg) + return cfg, nil +} + +// Save writes cfg to path atomically (safe for flash / SD storage). +func Save(path string, cfg *TUIConfig) error { + var buf bytes.Buffer + enc := toml.NewEncoder(&buf) + if err := enc.Encode(cfg); err != nil { + return fmt.Errorf("failed to encode config: %w", err) + } + if err := fileutil.WriteFileAtomic(path, buf.Bytes(), 0o600); err != nil { + return fmt.Errorf("failed to write config file %q: %w", path, err) + } + return nil +} + +func applyDefaults(cfg *TUIConfig) { + if cfg.Version == "" { + cfg.Version = "1.0" + } + if cfg.Model.Type == "" { + cfg.Model.Type = "provider" + } + for i := range cfg.Provider.Schemes { + if cfg.Provider.Schemes[i].Type == "" { + cfg.Provider.Schemes[i].Type = "openai-compatible" + } + } + for i := range cfg.Provider.Users { + if cfg.Provider.Users[i].Type == "" { + cfg.Provider.Users[i].Type = "key" + } + } +} + +// SchemeByName returns the first Scheme whose Name matches, or nil. +func (p *Provider) SchemeByName(name string) *Scheme { + for i := range p.Schemes { + if p.Schemes[i].Name == name { + return &p.Schemes[i] + } + } + return nil +} + +// UsersForScheme returns all users whose Scheme field matches schemeName. +func (p *Provider) UsersForScheme(schemeName string) []User { + var out []User + for _, u := range p.Users { + if u.Scheme == schemeName { + out = append(out, u) + } + } + return out +} + +func (cfg *TUIConfig) CurrentModelLabel() string { + cur := cfg.Provider.Current + if cur.Model == "" { + return "(not configured)" + } + label := cur.Scheme + if label != "" { + label += " / " + } + return label + cur.Model +} diff --git a/cmd/picoclaw-launcher-tui/main.go b/cmd/picoclaw-launcher-tui/main.go new file mode 100644 index 000000000..3d7e62b08 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/main.go @@ -0,0 +1,33 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package main + +import ( + "fmt" + "os" + + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" + "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui" +) + +func main() { + configPath := tuicfg.DefaultConfigPath() + if len(os.Args) > 1 { + configPath = os.Args[1] + } + + cfg, err := tuicfg.Load(configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err) + os.Exit(1) + } + + app := ui.New(cfg, configPath) + if err := app.Run(); err != nil { + fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go new file mode 100644 index 000000000..c642a1753 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/app.go @@ -0,0 +1,123 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" +) + +// App is the root TUI application. +type App struct { + tapp *tview.Application + pages *tview.Pages + pageStack []string + cfg *tuicfg.TUIConfig + configPath string + homeRefreshFn func() +} + +// New creates and wires up the TUI application. +func New(cfg *tuicfg.TUIConfig, configPath string) *App { + a := &App{ + tapp: tview.NewApplication(), + pages: tview.NewPages(), + pageStack: []string{}, + cfg: cfg, + configPath: configPath, + } + + a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + return a.goBack() + } + return event + }) + + a.buildPages() + return a +} + +// Run starts the TUI event loop. +func (a *App) Run() error { + return a.tapp.SetRoot(a.pages, true).EnableMouse(true).Run() +} + +func (a *App) buildPages() { + a.pages.AddPage("home", a.newHomePage(), true, true) + a.pageStack = []string{"home"} +} + +func (a *App) navigateTo(name string, page tview.Primitive) { + a.pages.AddPage(name, page, true, false) + a.pageStack = append(a.pageStack, name) + a.pages.SwitchToPage(name) +} + +func (a *App) goBack() *tcell.EventKey { + if len(a.pageStack) <= 1 { + return nil + } + a.pageStack = a.pageStack[:len(a.pageStack)-1] + prev := a.pageStack[len(a.pageStack)-1] + if prev == "home" && a.homeRefreshFn != nil { + a.homeRefreshFn() + } + a.pages.SwitchToPage(prev) + return nil +} + +func (a *App) showModal(name string, primitive tview.Primitive) { + a.pages.AddPage(name, primitive, true, true) +} + +func (a *App) hideModal(name string) { + a.pages.HidePage(name) + a.pages.RemovePage(name) +} + +func (a *App) save() { + _ = tuicfg.Save(a.configPath, a.cfg) +} + +func (a *App) showError(msg string) { + modal := tview.NewModal(). + SetText("Error: " + msg). + AddButtons([]string{"OK"}). + SetDoneFunc(func(_ int, _ string) { + a.hideModal("error") + }) + a.showModal("error", modal) +} + +func (a *App) confirmDelete(label string, onConfirm func()) { + modal := tview.NewModal(). + SetText("Delete " + label + "?\nThis cannot be undone."). + AddButtons([]string{"Delete", "Cancel"}). + SetDoneFunc(func(_ int, buttonLabel string) { + a.hideModal("confirm-delete") + if buttonLabel == "Delete" { + onConfirm() + } + }) + a.showModal("confirm-delete", modal) +} + +func centeredForm(form *tview.Form, width, height int) tview.Primitive { + return tview.NewGrid(). + SetColumns(0, width, 0). + SetRows(0, height, 0). + AddItem(form, 1, 1, 1, 1, 0, 0, true) +} + +func hintBar(text string) *tview.TextView { + tv := tview.NewTextView(). + SetText(text). + SetTextAlign(tview.AlignCenter) + tv.SetBackgroundColor(tcell.ColorDarkBlue) + return tv +} diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go new file mode 100644 index 000000000..6235a2c8e --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/home.go @@ -0,0 +1,43 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func (a *App) newHomePage() tview.Primitive { + list := tview.NewList() + list.SetBorder(true).SetTitle(" picoclaw-launcher-tui ") + + rebuildList := func() { + sel := list.GetCurrentItem() + list.Clear() + list.AddItem("model: "+a.cfg.CurrentModelLabel(), "Enter to configure", 'm', func() { + a.pages.RemovePage("schemes") + a.navigateTo("schemes", a.newSchemesPage()) + }) + list.AddItem("Quit", "", 'q', func() { a.tapp.Stop() }) + if sel > 0 && sel < list.GetItemCount() { + list.SetCurrentItem(sel) + } + } + rebuildList() + + a.homeRefreshFn = rebuildList + + list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + return event + }) + + footer := hintBar(" Enter: select q: quit ") + + return tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(list, 0, 1, true). + AddItem(footer, 1, 0, false) +} diff --git a/cmd/picoclaw-launcher-tui/ui/models.go b/cmd/picoclaw-launcher-tui/ui/models.go new file mode 100644 index 000000000..5e102d94c --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/models.go @@ -0,0 +1,143 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" +) + +type modelsAPIResponse struct { + Data []modelEntry `json:"data"` +} + +type modelEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive { + table := tview.NewTable(). + SetBorders(false). + SetSelectable(true, false). + SetFixed(0, 0) + table.SetBorder(true).SetTitle(fmt.Sprintf(" Models %s / %s ", schemeName, userName)) + + var modelIDs []string + + status := tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetDynamicColors(true). + SetText("[yellow]Fetching models…[-]") + + footer := hintBar(" Enter: select ESC: back ") + + flex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(status, 1, 0, false). + AddItem(table, 0, 1, false). + AddItem(footer, 1, 0, false) + + apiKey := a.resolveKey(schemeName, userName) + + go func() { + entries, err := fetchModels(baseURL, apiKey) + a.tapp.QueueUpdateDraw(func() { + if err != nil { + status.SetText(fmt.Sprintf("[red]Error: %s[-]", err.Error())) + table.SetCell(0, 0, tview.NewTableCell("(failed to load models)")) + a.tapp.SetFocus(table) + return + } + if len(entries) == 0 { + status.SetText("[yellow]No models returned[-]") + table.SetCell(0, 0, tview.NewTableCell("(no models available)")) + a.tapp.SetFocus(table) + return + } + + status.SetText(fmt.Sprintf("[green]%d model(s) loaded[-]", len(entries))) + for i, m := range entries { + modelIDs = append(modelIDs, m.ID) + table.SetCell(i, 0, + tview.NewTableCell(fmt.Sprintf("%3d", i+1)). + SetAlign(tview.AlignRight). + SetTextColor(tcell.ColorGray). + SetSelectable(false), + ) + table.SetCell(i, 1, + tview.NewTableCell(" "+m.ID). + SetAlign(tview.AlignLeft). + SetExpansion(1), + ) + } + a.tapp.SetFocus(table) + }) + }() + + table.SetSelectedFunc(func(row, _ int) { + if row < 0 || row >= len(modelIDs) { + return + } + a.cfg.Provider.Current = tuicfg.ProviderCurrent{ + Scheme: schemeName, + User: userName, + Model: modelIDs[row], + } + a.save() + a.goBack() + }) + + return flex +} + +func (a *App) resolveKey(schemeName, userName string) string { + for _, u := range a.cfg.Provider.Users { + if u.Scheme == schemeName && u.Name == userName { + return u.Key + } + } + return "" +} + +func fetchModels(baseURL, apiKey string) ([]modelEntry, error) { + url := strings.TrimRight(baseURL, "/") + "/models" + + client := &http.Client{Timeout: 15 * time.Second} + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var result modelsAPIResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return result.Data, nil +} diff --git a/cmd/picoclaw-launcher-tui/ui/schemes.go b/cmd/picoclaw-launcher-tui/ui/schemes.go new file mode 100644 index 000000000..eec3bda7c --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/schemes.go @@ -0,0 +1,147 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" +) + +func (a *App) newSchemesPage() tview.Primitive { + list := tview.NewList() + list.SetBorder(true).SetTitle(" Provider Schemes (a:add e:edit d:delete Enter:users) ") + + rebuild := func() { + sel := list.GetCurrentItem() + list.Clear() + for _, s := range a.cfg.Provider.Schemes { + name := s.Name + list.AddItem( + fmt.Sprintf("%s · %s [%s]", s.Name, s.BaseURL, s.Type), + "", + 0, + func() { + a.pages.RemovePage("users") + a.navigateTo("users", a.newUsersPage(name)) + }, + ) + } + if sel >= 0 && sel < list.GetItemCount() { + list.SetCurrentItem(sel) + } + } + rebuild() + + list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Rune() { + case 'a': + a.showSchemeForm(nil, func(s tuicfg.Scheme) { + a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s) + a.save() + rebuild() + }) + return nil + case 'e': + idx := list.GetCurrentItem() + if idx < 0 || idx >= len(a.cfg.Provider.Schemes) { + return nil + } + orig := a.cfg.Provider.Schemes[idx] + a.showSchemeForm(&orig, func(s tuicfg.Scheme) { + a.cfg.Provider.Schemes[idx] = s + a.save() + rebuild() + }) + return nil + case 'd': + idx := list.GetCurrentItem() + if idx < 0 || idx >= len(a.cfg.Provider.Schemes) { + return nil + } + name := a.cfg.Provider.Schemes[idx].Name + a.confirmDelete(fmt.Sprintf("scheme %q", name), func() { + schemes := a.cfg.Provider.Schemes + a.cfg.Provider.Schemes = append(schemes[:idx], schemes[idx+1:]...) + a.save() + rebuild() + }) + return nil + } + return event + }) + + footer := hintBar(" Enter: users a: add e: edit d: delete ESC: back ") + + return tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(list, 0, 1, true). + AddItem(footer, 1, 0, false) +} + +func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) { + name := "" + baseURL := "" + schemeType := "openai-compatible" + title := " Add Scheme " + + if existing != nil { + name = existing.Name + baseURL = existing.BaseURL + schemeType = existing.Type + title = " Edit Scheme " + } + + typeOptions := []string{"openai-compatible", "anthropic"} + typeIdx := 0 + for i, t := range typeOptions { + if t == schemeType { + typeIdx = i + break + } + } + + form := tview.NewForm() + + var nameField *tview.InputField + + form. + AddInputField("Name", name, 40, nil, func(text string) { name = text }). + AddInputField("Base URL", baseURL, 60, nil, func(text string) { baseURL = text }). + AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }). + AddButton("Save", func() { + _ = nameField + if name == "" { + a.showError("Name is required") + return + } + if baseURL == "" { + a.showError("Base URL is required") + return + } + if existing == nil { + for _, s := range a.cfg.Provider.Schemes { + if s.Name == name { + a.showError(fmt.Sprintf("Scheme name %q already exists", name)) + return + } + } + } + a.hideModal("scheme-form") + onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType}) + }). + AddButton("Cancel", func() { + a.hideModal("scheme-form") + }) + + nameField, _ = form.GetFormItemByLabel("Name").(*tview.InputField) + + form.SetBorder(true).SetTitle(title) + + a.showModal("scheme-form", centeredForm(form, 68, 12)) +} diff --git a/cmd/picoclaw-launcher-tui/ui/users.go b/cmd/picoclaw-launcher-tui/ui/users.go new file mode 100644 index 000000000..27b7cea7a --- /dev/null +++ b/cmd/picoclaw-launcher-tui/ui/users.go @@ -0,0 +1,161 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package ui + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" +) + +func (a *App) newUsersPage(schemeName string) tview.Primitive { + list := tview.NewList() + list.SetBorder(true).SetTitle(fmt.Sprintf(" Users for scheme %q (a:add e:edit d:delete Enter:models) ", schemeName)) + + indexInCfg := func(visibleIdx int) int { + count := 0 + for i, u := range a.cfg.Provider.Users { + if u.Scheme == schemeName { + if count == visibleIdx { + return i + } + count++ + } + } + return -1 + } + + rebuild := func() { + sel := list.GetCurrentItem() + list.Clear() + for _, u := range a.cfg.Provider.Users { + if u.Scheme != schemeName { + continue + } + uName := u.Name + uType := u.Type + list.AddItem( + fmt.Sprintf("%s · %s", u.Name, uType), + "", + 0, + func() { + a.pages.RemovePage("models") + scheme := a.cfg.Provider.SchemeByName(schemeName) + if scheme == nil { + a.showError(fmt.Sprintf("Scheme %q not found", schemeName)) + return + } + a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL)) + }, + ) + } + if sel >= 0 && sel < list.GetItemCount() { + list.SetCurrentItem(sel) + } + } + rebuild() + + list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Rune() { + case 'a': + a.showUserForm(schemeName, nil, func(u tuicfg.User) { + a.cfg.Provider.Users = append(a.cfg.Provider.Users, u) + a.save() + rebuild() + }) + return nil + case 'e': + visIdx := list.GetCurrentItem() + cfgIdx := indexInCfg(visIdx) + if cfgIdx < 0 { + return nil + } + orig := a.cfg.Provider.Users[cfgIdx] + a.showUserForm(schemeName, &orig, func(u tuicfg.User) { + a.cfg.Provider.Users[cfgIdx] = u + a.save() + rebuild() + }) + return nil + case 'd': + visIdx := list.GetCurrentItem() + cfgIdx := indexInCfg(visIdx) + if cfgIdx < 0 { + return nil + } + uName := a.cfg.Provider.Users[cfgIdx].Name + a.confirmDelete(fmt.Sprintf("user %q", uName), func() { + users := a.cfg.Provider.Users + a.cfg.Provider.Users = append(users[:cfgIdx], users[cfgIdx+1:]...) + a.save() + rebuild() + }) + return nil + } + return event + }) + + footer := hintBar(" Enter: select model a: add e: edit d: delete ESC: back ") + + return tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(list, 0, 1, true). + AddItem(footer, 1, 0, false) +} + +func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) { + name := "" + userType := "key" + key := "" + title := " Add User " + + if existing != nil { + name = existing.Name + userType = existing.Type + key = existing.Key + title = " Edit User " + } + + typeOptions := []string{"key", "OAuth"} + typeIdx := 0 + for i, t := range typeOptions { + if t == userType { + typeIdx = i + break + } + } + + form := tview.NewForm() + form. + AddInputField("Name", name, 40, nil, func(text string) { name = text }). + AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }). + AddPasswordField("Key", key, 60, '*', func(text string) { key = text }). + AddButton("Save", func() { + if name == "" { + a.showError("Name is required") + return + } + if existing == nil { + for _, u := range a.cfg.Provider.Users { + if u.Scheme == schemeName && u.Name == name { + a.showError(fmt.Sprintf("User name %q already exists for this scheme", name)) + return + } + } + } + a.hideModal("user-form") + onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key}) + }). + AddButton("Cancel", func() { + a.hideModal("user-form") + }) + + form.SetBorder(true).SetTitle(title) + + a.showModal("user-form", centeredForm(form, 68, 13)) +} diff --git a/go.mod b/go.mod index 39385edca..cfc930d37 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/sipeed/picoclaw go 1.25.8 require ( + github.com/BurntSushi/toml v1.6.0 fyne.io/systray v1.12.0 github.com/adhocore/gronx v1.19.6 github.com/anthropics/anthropic-sdk-go v1.26.0 diff --git a/go.sum b/go.sum index 3e6001480..f24b997d4 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=