feat: implement TUI configuration and user management for picoclaw-launcher-tui

This commit is contained in:
taorye
2026-03-20 11:54:58 +08:00
parent 998b456b65
commit 5a199ec993
9 changed files with 812 additions and 0 deletions
+159
View File
@@ -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 <baseURL>/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
}
+33
View File
@@ -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)
}
}
+123
View File
@@ -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
}
+43
View File
@@ -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)
}
+143
View File
@@ -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
}
+147
View File
@@ -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))
}
+161
View File
@@ -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))
}
+1
View File
@@ -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
+2
View File
@@ -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=