Add CLI support for custom OpenAI-compatible endpoints and remove TUI (#2710)

* feat(model): add `picoclaw model add` for custom OpenAI-compatible endpoints

Onboards a model from a user-supplied API base + key by hitting
GET <base>/models, prompting the user to pick one, and writing the entry
into model_list[] (with api_keys) plus setting it as the default model.
This was previously only available in the TUI launcher (issue #2208) and
is now accessible from the CLI:

    picoclaw model add -b URL -k KEY [-m MODEL] [-n ALIAS]

* chore: remove deprecated picoclaw-launcher-tui

Per RFC #2208, the TUI launcher is deprecated in favor of the CLI; its
"online model picker" feature has been ported to `picoclaw model add` in
the previous commit. This drops the binary and all build/release/docs
references:

- delete cmd/picoclaw-launcher-tui/ and assets/launcher-tui.jpg
- Makefile: remove the `build-launcher-tui` target
- .goreleaser.yaml: drop the build entry plus the `picoclaw-launcher-tui`
  ids from the launcher docker image, macOS notarize list, and nfpms
  contents
- docker/Dockerfile.goreleaser.launcher: drop the COPY for the TUI binary
- READMEs (root + 8 locales): remove the "TUI Launcher" section and
  screenshot link
- docs/guides/docker.*: update the "launcher image includes …" sentence
  to reflect the two remaining binaries

`make build` still succeeds; `go build ./web/backend` (the launcher
target) still succeeds. `picoclaw-launcher` (web console) is unaffected.
This commit is contained in:
taonyx
2026-04-29 17:52:47 +08:00
committed by GitHub
parent 62d0e34ec9
commit a36472b55f
37 changed files with 553 additions and 2153 deletions
-46
View File
@@ -100,49 +100,6 @@ builds:
- goos: netbsd
goarch: arm
- id: picoclaw-launcher-tui
binary: picoclaw-launcher-tui
env:
- CGO_ENABLED=0
tags:
- goolm
- stdjson
ldflags:
- -s -w
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
goos:
- linux
- windows
- darwin
- freebsd
- netbsd
goarch:
- amd64
- arm64
- riscv64
- loong64
- arm
- s390x
- mipsle
goarm:
- "6"
- "7"
gomips:
- softfloat
main: ./cmd/picoclaw-launcher-tui
ignore:
- goos: windows
goarch: arm
- goos: netbsd
goarch: s390x
- goos: netbsd
goarch: mips64
- goos: netbsd
goarch: arm
dockers_v2:
- id: picoclaw
dockerfile: docker/Dockerfile.goreleaser
@@ -166,7 +123,6 @@ dockers_v2:
ids:
- picoclaw
- picoclaw-launcher
- picoclaw-launcher-tui
images:
- "ghcr.io/{{ .Env.REPO_OWNER }}/picoclaw"
- '{{ with .Env.DOCKERHUB_IMAGE_NAME }}docker.io/{{ . }}{{ end }}'
@@ -184,7 +140,6 @@ notarize:
ids:
- picoclaw
- picoclaw-launcher
- picoclaw-launcher-tui
sign:
certificate: "{{.Env.MACOS_SIGN_P12}}"
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
@@ -215,7 +170,6 @@ nfpms:
ids:
- picoclaw
- picoclaw-launcher
- picoclaw-launcher-tui
package_name: picoclaw
file_name_template: >-
{{ .PackageName }}_
-14
View File
@@ -236,20 +236,6 @@ endif
build-launcher-frontend:
@$(MAKE) -C web build-frontend
## build-launcher-tui: Build the picoclaw-launcher TUI binary
build-launcher-tui:
@echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..."
ifeq ($(OS),Windows_NT)
@$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null"
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH)$(EXT) ./cmd/picoclaw-launcher-tui
@$(POWERSHELL) "Copy-Item -LiteralPath '$(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH)$(EXT)' -Destination '$(BUILD_DIR)/picoclaw-launcher-tui$(EXT)' -Force"
else
@mkdir -p $(BUILD_DIR)
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) ./cmd/picoclaw-launcher-tui
@ln -sf picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher-tui
endif
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-tui$(EXT)"
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
build-whatsapp-native: generate
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
-18
View File
@@ -291,24 +291,6 @@ After this one-time step, `picoclaw-launcher` will open normally on subsequent l
</details>
### 💻 TUI Launcher (Recommended for Headless / SSH)
The TUI (Terminal UI) Launcher provides a full-featured terminal interface for configuration and management. Ideal for servers, Raspberry Pi, and other headless environments.
```bash
picoclaw-launcher-tui
```
<p align="center">
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
</p>
**Getting started:**
Use the TUI menus to: **1)** Configure a Provider -> **2)** Configure a Channel -> **3)** Start the Gateway -> **4)** Chat!
For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io).
<a id="-run-on-old-android-phones"></a>
### 📱 Android
Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

-69
View File
@@ -1,69 +0,0 @@
# Picoclaw Launcher TUI
This directory contains the terminal-based TUI launcher for `picoclaw`.
It provides a lightweight, terminal-native user interface for managing, configuring, and interacting with the core `picoclaw` engine, without requiring a web browser or graphical environment.
## Architecture
The TUI launcher is implemented purely in Go with no external runtime dependencies:
* **`main.go`**: Application entry point, handles initialization and main event loop
* **`ui/`**: TUI interface components built on tview + tcell framework:
- `home.go`: Main dashboard with navigation menu
- `schemes.go`: AI model scheme management
- `users.go`: User and API key management for model providers
- `channels.go`: Communication channel (Telegram/Discord/WeChat etc.) configuration editor
- `gateway.go`: PicoClaw gateway daemon lifecycle management (start/stop/status)
- `app.go`: Core TUI application framework and navigation logic
- `models.go`: Data structures and state management
* **`config/`**: Configuration management layer, integrates with the core picoclaw configuration system
## Getting Started
### Prerequisites
* Go 1.25+
* Terminal with 256-color support (most modern terminals are compatible)
### Development
Run the TUI launcher directly in development mode:
```bash
# From project root
go run ./cmd/picoclaw-launcher-tui
# Or from this directory
go run .
```
### Build
Build the standalone TUI launcher binary:
```bash
# From project root (recommended)
make build-launcher-tui
# Output will be at:
# build/picoclaw-launcher-tui-<platform>-<arch>
# with symlink build/picoclaw-launcher-tui
# Or build directly from this directory
go build -o picoclaw-launcher-tui .
```
### Key Features
* 🖥️ Terminal-native interface - works over SSH, on headless servers, and in low-resource environments
* ⚙️ AI model scheme and API key management
* 📱 Communication channel configuration editor (Telegram/Discord/WeChat etc.)
* 🔄 PicoClaw gateway daemon management (start/stop/status monitoring)
* 💬 One-click launch of interactive AI chat session
* 🎯 Keyboard-first design with intuitive shortcuts
### Other Commands
```bash
# Run with custom config file path
go run . /path/to/custom/config.json
```
-236
View File
@@ -1,236 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
// Package config provides types and I/O for ~/.picoclaw/tui.toml.
package config
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/BurntSushi/toml"
"github.com/sipeed/picoclaw/pkg/fileutil"
)
// DefaultConfigPath returns the default path to the tui.toml config file.
func DefaultConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
return filepath.Join(home, ".picoclaw", "tui.toml")
}
// TUIConfig is the top-level structure of ~/.picoclaw/tui.toml.
type TUIConfig struct {
Version string `toml:"version"`
Model Model `toml:"model"`
Provider Provider `toml:"provider"`
}
type Model struct {
Type string `toml:"type"` // "provider" (default) | "manual"
}
type Provider struct {
Schemes []Scheme `toml:"schemes"`
Users []User `toml:"users"`
Current ProviderCurrent `toml:"current"`
}
type Scheme struct {
Name string `toml:"name"` // unique key
BaseURL string `toml:"baseURL"` // required
Type string `toml:"type"` // "openai-compatible" (default) | "anthropic"
}
type User struct {
Name string `toml:"name"`
Scheme string `toml:"scheme"` // references Scheme.Name; (Name+Scheme) is unique
Type string `toml:"type"` // "key" (default) | "OAuth"
Key string `toml:"key"`
}
type ProviderCurrent struct {
Scheme string `toml:"scheme"` // references Scheme.Name
User string `toml:"user"` // references User.Name where User.Scheme == Scheme
Model string `toml:"model"` // from GET <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 {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
var buf bytes.Buffer
enc := toml.NewEncoder(&buf)
if err := enc.Encode(cfg); err != nil {
return fmt.Errorf("failed to encode config: %w", err)
}
if err := fileutil.WriteFileAtomic(path, buf.Bytes(), 0o600); err != nil {
return fmt.Errorf("failed to write config file %q: %w", path, err)
}
return nil
}
func applyDefaults(cfg *TUIConfig) {
if cfg.Version == "" {
cfg.Version = "1.0"
}
if cfg.Model.Type == "" {
cfg.Model.Type = "provider"
}
for i := range cfg.Provider.Schemes {
if cfg.Provider.Schemes[i].Type == "" {
cfg.Provider.Schemes[i].Type = "openai-compatible"
}
}
for i := range cfg.Provider.Users {
if cfg.Provider.Users[i].Type == "" {
cfg.Provider.Users[i].Type = "key"
}
}
}
// SchemeByName returns the first Scheme whose Name matches, or nil.
func (p *Provider) SchemeByName(name string) *Scheme {
for i := range p.Schemes {
if p.Schemes[i].Name == name {
return &p.Schemes[i]
}
}
return nil
}
// UsersForScheme returns all users whose Scheme field matches schemeName.
func (p *Provider) UsersForScheme(schemeName string) []User {
var out []User
for _, u := range p.Users {
if u.Scheme == schemeName {
out = append(out, u)
}
}
return out
}
// SyncSelectedModelToMainConfig syncs the currently selected model to ~/.picoclaw/config.json
// Adds/replaces a "tui-prefer" model entry and sets it as the default model.
// Preserves all other existing fields in the config file unchanged.
func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) error {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
mainConfigPath := filepath.Join(home, ".picoclaw", "config.json")
var cfg map[string]any
if data, readErr := os.ReadFile(mainConfigPath); readErr == nil {
if unmarshalErr := json.Unmarshal(data, &cfg); unmarshalErr != nil {
cfg = make(map[string]any)
}
} else {
cfg = make(map[string]any)
}
if _, ok := cfg["agents"]; !ok {
cfg["agents"] = make(map[string]any)
}
agents, ok := cfg["agents"].(map[string]any)
if ok {
if _, ok := agents["defaults"]; !ok {
agents["defaults"] = make(map[string]any)
}
defaults, ok := agents["defaults"].(map[string]any)
if ok {
defaults["model"] = "tui-prefer"
}
}
tuiModel := map[string]any{
"model_name": "tui-prefer",
"model": modelID,
"api_key": user.Key,
"api_base": scheme.BaseURL,
}
modelList := []any{}
if ml, ok := cfg["model_list"].([]any); ok {
modelList = ml
}
found := false
for i, m := range modelList {
if entry, ok := m.(map[string]any); ok {
if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" {
modelList[i] = tuiModel
found = true
break
}
}
}
if !found {
modelList = append(modelList, tuiModel)
}
cfg["model_list"] = modelList
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(mainConfigPath), 0o700); err != nil {
return err
}
return os.WriteFile(mainConfigPath, data, 0o600)
}
func (cfg *TUIConfig) CurrentModelLabel() string {
cur := cfg.Provider.Current
if cur.Model == "" {
return "(not configured)"
}
label := cur.Scheme
if label != "" {
label += " / "
}
return label + cur.Model
}
-48
View File
@@ -1,48 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
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]
}
configDir := filepath.Dir(configPath)
if _, err := os.Stat(configDir); os.IsNotExist(err) {
cmd := exec.Command("picoclaw", "onboard")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
}
cfg, err := tuicfg.Load(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
os.Exit(1)
}
app := ui.New(cfg, configPath)
// Bind model selection hook to sync to main config
app.OnModelSelected = func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) {
_ = tuicfg.SyncSelectedModelToMainConfig(scheme, user, modelID)
}
if err := app.Run(); err != nil {
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
os.Exit(1)
}
}
-325
View File
@@ -1,325 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"sync"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
// App is the root TUI application.
type App struct {
tapp *tview.Application
pages *tview.Pages
pageStack []string
cfg *tuicfg.TUIConfig
configPath string
pageRefreshFns map[string]func()
headerModelTV *tview.TextView
modalOpen map[string]bool
// OnModelSelected is called when a model is selected in the UI.
// Can be nil to disable.
OnModelSelected func(scheme tuicfg.Scheme, user tuicfg.User, modelID string)
modelCache map[string][]modelEntry
modelCacheMu sync.RWMutex
refreshMu sync.Mutex
}
// cacheKey returns the map key for a (scheme, user) pair.
func cacheKey(schemeName, userName string) string {
return fmt.Sprintf("%s/%s", schemeName, userName)
}
// cachedModels returns a defensive copy of the cached model list for a user (may be nil).
func (a *App) cachedModels(schemeName, userName string) []modelEntry {
a.modelCacheMu.RLock()
defer a.modelCacheMu.RUnlock()
entries := a.modelCache[cacheKey(schemeName, userName)]
return append([]modelEntry(nil), entries...)
}
// refreshModelCache fetches models for every user in the config concurrently.
// Serialized by refreshMu so concurrent calls don't race on the cache map.
// When all fetches complete it calls onDone via QueueUpdateDraw.
func (a *App) refreshModelCache(onDone func()) {
go func() {
a.refreshMu.Lock()
defer a.refreshMu.Unlock()
users := a.cfg.Provider.Users
schemes := a.cfg.Provider.Schemes
schemeURL := make(map[string]string, len(schemes))
for _, s := range schemes {
schemeURL[s.Name] = s.BaseURL
}
var wg sync.WaitGroup
for _, u := range users {
baseURL, ok := schemeURL[u.Scheme]
if !ok || baseURL == "" {
continue
}
if u.Key == "" {
a.modelCacheMu.Lock()
if a.modelCache == nil {
a.modelCache = make(map[string][]modelEntry)
}
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
a.modelCacheMu.Unlock()
continue
}
wg.Add(1)
bURL := baseURL
go func() {
defer wg.Done()
entries, err := fetchModels(bURL, u.Key)
a.modelCacheMu.Lock()
if a.modelCache == nil {
a.modelCache = make(map[string][]modelEntry)
}
if err != nil || len(entries) == 0 {
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
} else {
a.modelCache[cacheKey(u.Scheme, u.Name)] = entries
}
a.modelCacheMu.Unlock()
}()
}
wg.Wait()
if onDone != nil {
a.tapp.QueueUpdateDraw(onDone)
}
}()
}
// New creates and wires up the TUI application.
func New(cfg *tuicfg.TUIConfig, configPath string) *App {
// Cyberpunk Theme Colors
// Dark background
tview.Styles.PrimitiveBackgroundColor = tcell.NewHexColor(0x050510) // Deep Void
tview.Styles.ContrastBackgroundColor = tcell.NewHexColor(0x1a1a2e) // Dark Indigo
tview.Styles.MoreContrastBackgroundColor = tcell.NewHexColor(0x2a2a40)
// Borders and Titles
tview.Styles.BorderColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
tview.Styles.TitleColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
tview.Styles.GraphicsColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
// Text
tview.Styles.PrimaryTextColor = tcell.NewHexColor(0xe0e0e0) // Off-white
tview.Styles.SecondaryTextColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
tview.Styles.TertiaryTextColor = tcell.NewHexColor(0x39ff14) // Neon Lime
tview.Styles.InverseTextColor = tcell.NewHexColor(0x000000) // Black
tview.Styles.ContrastSecondaryTextColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
a := &App{
tapp: tview.NewApplication(),
pages: tview.NewPages(),
pageStack: []string{},
cfg: cfg,
configPath: configPath,
pageRefreshFns: make(map[string]func()),
modalOpen: make(map[string]bool),
}
a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
if len(a.modalOpen) > 0 {
return event
}
return a.goBack()
}
return event
})
a.buildPages()
return a
}
// Run starts the TUI event loop.
func (a *App) Run() error {
return a.tapp.SetRoot(a.pages, true).EnableMouse(true).Run()
}
func (a *App) buildPages() {
a.pages.AddPage("home", a.newHomePage(), true, true)
a.pageStack = []string{"home"}
}
func (a *App) navigateTo(name string, page tview.Primitive) {
a.pages.RemovePage(name)
a.pages.AddPage(name, page, true, false)
a.pageStack = append(a.pageStack, name)
a.pages.SwitchToPage(name)
}
func (a *App) goBack() *tcell.EventKey {
if len(a.pageStack) <= 1 {
return nil
}
popped := a.pageStack[len(a.pageStack)-1]
a.pageStack = a.pageStack[:len(a.pageStack)-1]
a.pages.RemovePage(popped)
prev := a.pageStack[len(a.pageStack)-1]
if fn, ok := a.pageRefreshFns[prev]; ok {
fn()
}
if prev == "home" && a.headerModelTV != nil {
a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ")
}
a.pages.SwitchToPage(prev)
return nil
}
func (a *App) showModal(name string, primitive tview.Primitive) {
a.modalOpen[name] = true
a.pages.AddPage(name, primitive, true, true)
}
func (a *App) hideModal(name string) {
delete(a.modalOpen, name)
a.pages.HidePage(name)
a.pages.RemovePage(name)
}
func (a *App) save() {
if err := tuicfg.Save(a.configPath, a.cfg); err != nil {
a.showError("save failed: " + err.Error())
}
}
func (a *App) showError(msg string) {
modal := tview.NewModal().
SetText(" [red::b]ERROR[-::-]\n\n" + msg).
AddButtons([]string{"OK"}).
SetDoneFunc(func(_ int, _ string) {
a.hideModal("error")
})
// Cyberpunk Modal Style
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
a.showModal("error", modal)
}
func (a *App) confirmDelete(label string, onConfirm func()) {
modal := tview.NewModal().
SetText(" [red::b]DELETE WARNING[-::-]\n\nDelete " + label + "?\n[gray]This action cannot be undone.[-]").
AddButtons([]string{"Delete", "Cancel"}).
SetDoneFunc(func(_ int, buttonLabel string) {
a.hideModal("confirm-delete")
if buttonLabel == "Delete" {
onConfirm()
}
})
// Cyberpunk Modal Style
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red for danger
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
a.showModal("confirm-delete", modal)
}
func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive {
return tview.NewFlex().
AddItem(tview.NewBox(), 0, 1, false).
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(tview.NewBox(), 0, 1, false).
AddItem(form, height, 1, true).
AddItem(tview.NewBox(), 0, 1, false), 0, widthPct, true).
AddItem(tview.NewBox(), 0, 1, false)
}
func hintBar(text string) *tview.TextView {
tv := tview.NewTextView().
SetText(text).
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter).
SetTextColor(tcell.NewHexColor(0x00f0ff)) // Neon Cyan
tv.SetBackgroundColor(tcell.NewHexColor(0x2a2a40)) // Darker Indigo
return tv
}
func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tview.Primitive {
var modelTV *tview.TextView
if pageID == "home" {
if a.headerModelTV == nil {
a.headerModelTV = tview.NewTextView()
a.headerModelTV.SetTextAlign(tview.AlignRight).
SetTextColor(tcell.NewHexColor(0x39ff14)). // Neon Lime
SetDynamicColors(true).
SetBackgroundColor(tcell.NewHexColor(0x050510))
}
modelTV = a.headerModelTV
modelTV.SetText("MODEL: " + a.cfg.CurrentModelLabel() + " ")
} else {
modelTV = tview.NewTextView()
modelTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
}
headerLeft := tview.NewTextView().
SetText(" [#ff00ff::b]///[#00f0ff] PICOCLAW LAUNCHER [#ff00ff]///").
SetDynamicColors(true).
SetBackgroundColor(tcell.NewHexColor(0x050510))
header := tview.NewFlex().
AddItem(headerLeft, 0, 1, false).
AddItem(modelTV, 0, 1, false)
sidebar := tview.NewTextView().
SetDynamicColors(true).
SetWrap(false)
sidebar.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
// Cyberpunk Sidebar Styling
activePrefix := "[#39ff14::b]>> " // Neon Lime arrow
activeSuffix := "[-]"
inactivePrefix := "[#808080] "
inactiveSuffix := "[-]"
sbText := "\n\n" // Top padding
menuItem := func(id, label string) string {
if pageID == id {
return activePrefix + label + activeSuffix + "\n\n"
}
return inactivePrefix + label + inactiveSuffix + "\n\n"
}
sbText += menuItem("home", "HOME")
sbText += menuItem("schemes", "SCHEMES")
sbText += menuItem("users", "USERS")
sbText += menuItem("models", "MODELS")
sbText += menuItem("channels", "CHANNELS")
sbText += menuItem("gateway", "GATEWAY")
sidebar.SetText(sbText)
footer := hintBar(hint)
grid := tview.NewGrid().
SetRows(1, 0, 1).
SetColumns(20, 0). // Slightly wider sidebar
AddItem(header, 0, 0, 1, 2, 0, 0, false).
AddItem(sidebar, 1, 0, 1, 1, 0, 0, false).
AddItem(content, 1, 1, 1, 1, 0, 0, true).
AddItem(footer, 2, 0, 1, 2, 0, 0, false)
// Add a border around the content area if possible, or ensure content has its own border
// grid.SetBorders(false) // Grid borders usually look bad, handled by components
return grid
}
-202
View File
@@ -1,202 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"strconv"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func (a *App) newChannelsPage() tview.Primitive {
list := tview.NewList()
list.SetBorder(true).
SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
list.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)),
)
list.SetHighlightFullLine(true)
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
rebuild := func() {
sel := list.GetCurrentItem()
list.Clear()
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
configPath := filepath.Join(home, ".picoclaw", "config.json")
var cfg map[string]any
if data, err := os.ReadFile(configPath); err == nil {
_ = json.Unmarshal(data, &cfg)
}
if chRaw, ok := cfg["channels"].(map[string]any); ok {
for name, ch := range chRaw {
chMap, ok := ch.(map[string]any)
enabled := "disabled"
if ok {
if e, ok := chMap["enabled"].(bool); ok && e {
enabled = "enabled"
}
}
list.AddItem(name, fmt.Sprintf("Status: %s", enabled), 0, func() {
a.showChannelEditForm(configPath, name, chMap)
})
}
}
if sel >= 0 && sel < list.GetItemCount() {
list.SetCurrentItem(sel)
}
}
rebuild()
a.pageRefreshFns["channels"] = rebuild
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
return a.goBack()
}
return event
})
return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ")
}
func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]any) {
form := tview.NewForm()
form.SetBorder(true).
SetTitle(" [::b]EDIT CHANNEL ").
SetTitleColor(tcell.NewHexColor(0x39ff14)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
fields := make(map[string]*tview.InputField)
var nameField *tview.InputField
if channelName == "" {
nameField = tview.NewInputField().
SetLabel("Channel Name").
SetText("").
SetFieldWidth(28)
form.AddFormItem(nameField)
}
for k, v := range existing {
if reflect.ValueOf(v).Kind() == reflect.Map || reflect.ValueOf(v).Kind() == reflect.Slice {
continue
}
valStr := fmt.Sprintf("%v", v)
field := tview.NewInputField().
SetLabel(k).
SetText(valStr).
SetFieldWidth(28)
form.AddFormItem(field)
fields[k] = field
}
form.AddButton("SAVE", func() {
var cfg map[string]any
if data, err := os.ReadFile(configPath); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
cfg = make(map[string]any)
}
} else {
cfg = make(map[string]any)
}
if _, ok := cfg["channels"]; !ok {
cfg["channels"] = make(map[string]any)
}
channels, ok := cfg["channels"].(map[string]any)
if !ok {
channels = make(map[string]any)
cfg["channels"] = channels
}
finalName := channelName
if channelName == "" {
if nameField == nil || nameField.GetText() == "" {
a.showError("Channel name is required")
return
}
finalName = nameField.GetText()
}
updated := make(map[string]any)
if existing != nil {
for k, v := range existing {
updated[k] = v
}
}
for k, field := range fields {
val := field.GetText()
if val == "true" {
updated[k] = true
} else if val == "false" {
updated[k] = false
} else if num, err := strconv.Atoi(val); err == nil {
updated[k] = num
} else {
updated[k] = val
}
}
if channelName != "" && finalName != channelName {
delete(channels, channelName)
}
channels[finalName] = updated
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
a.showError(fmt.Sprintf("Failed to save config: %v", err))
return
}
if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
a.showError(fmt.Sprintf("Failed to create config directory: %v", err))
return
}
if err := os.WriteFile(configPath, data, 0o600); err != nil {
a.showError(fmt.Sprintf("Failed to write config: %v", err))
return
}
a.hideModal("channel-edit")
a.goBack()
})
form.AddButton("CANCEL", func() {
a.hideModal("channel-edit")
})
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
a.hideModal("channel-edit")
return nil
}
return event
})
a.showModal("channel-edit", centeredForm(form, 4, 20))
}
-229
View File
@@ -1,229 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"github.com/sipeed/picoclaw/pkg/config"
ppid "github.com/sipeed/picoclaw/pkg/pid"
)
type gatewayStatus struct {
running bool
pid int
version string
}
func picoHome() string {
return config.GetHome()
}
func getGatewayStatus() gatewayStatus {
data := ppid.ReadPidFileWithCheck(picoHome())
if data == nil {
return gatewayStatus{running: false}
}
return gatewayStatus{
running: true,
pid: data.PID,
version: data.Version,
}
}
func startGateway() error {
status := getGatewayStatus()
if status.running {
return fmt.Errorf("gateway is already running (PID: %d)", status.pid)
}
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1")
} else {
cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 &")
}
err := cmd.Start()
if err != nil {
return err
}
time.Sleep(1 * time.Second)
if runtime.GOOS == "windows" {
cmd := exec.Command(
"wmic",
"process",
"where",
"name='picoclaw.exe' and commandline like '%gateway%'",
"get",
"processid",
)
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to get gateway PID: %w", err)
}
lines := strings.Split(string(output), "\n")
for _, line := range lines[1:] {
line = strings.TrimSpace(line)
if line == "" {
continue
}
_, err := strconv.Atoi(line)
if err == nil {
break
}
}
}
status = getGatewayStatus()
if !status.running {
return fmt.Errorf("failed to start gateway")
}
return nil
}
func stopGateway() error {
status := getGatewayStatus()
if !status.running {
return fmt.Errorf("gateway is not running")
}
var err error
if runtime.GOOS == "windows" {
err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run()
} else {
err = exec.Command("kill", strconv.Itoa(status.pid)).Run()
}
if err != nil {
return err
}
// Wait for process to stop (ReadPidFileWithCheck cleans up stale pid file)
for i := 0; i < 5; i++ {
if !getGatewayStatus().running {
break
}
time.Sleep(200 * time.Millisecond)
}
return nil
}
func (a *App) newGatewayPage() tview.Primitive {
flex := tview.NewFlex().SetDirection(tview.FlexRow)
flex.SetBorder(true).
SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
flex.SetBackgroundColor(tcell.NewHexColor(0x050510))
statusTV := tview.NewTextView().
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter).
SetText("Checking status...")
statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
var updateStatus func()
// 使用List作为按钮,保证显示和交互正常
buttons := tview.NewList()
buttons.SetBackgroundColor(tcell.NewHexColor(0x050510))
buttons.SetMainTextColor(tcell.ColorWhite)
buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff))
buttons.SetSelectedTextColor(tcell.ColorBlack)
buttons.AddItem(" [lime]START[white] ", "", 0, func() {
if !getGatewayStatus().running {
err := startGateway()
if err != nil {
a.showError(err.Error())
}
updateStatus()
}
})
buttons.AddItem(" [red]STOP[white] ", "", 0, func() {
if getGatewayStatus().running {
err := stopGateway()
if err != nil {
a.showError(err.Error())
}
updateStatus()
}
})
buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn)
buttonFlex.
AddItem(tview.NewBox(), 0, 1, false).
AddItem(buttons, 20, 1, true).
AddItem(tview.NewBox(), 0, 1, false)
flex.
AddItem(tview.NewBox(), 0, 1, false).
AddItem(statusTV, 3, 1, false).
AddItem(tview.NewBox(), 0, 1, false).
AddItem(buttonFlex, 4, 1, true).
AddItem(tview.NewBox(), 0, 1, false)
updateStatus = func() {
status := getGatewayStatus()
if status.running {
versionInfo := ""
if status.version != "" {
versionInfo = fmt.Sprintf("\nVersion: %s", status.version)
}
statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d%s", status.pid, versionInfo))
buttons.SetItemText(0, " [gray]START[white] ", "")
buttons.SetItemText(1, " [red]STOP[white] ", "")
} else {
statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A")
buttons.SetItemText(0, " [lime]START[white] ", "")
buttons.SetItemText(1, " [gray]STOP[white] ", "")
}
}
updateStatus()
done := make(chan struct{})
go func() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
a.tapp.QueueUpdateDraw(updateStatus)
case <-done:
return
}
}
}()
originalInputCapture := flex.GetInputCapture()
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
close(done)
return a.goBack()
}
if originalInputCapture != nil {
return originalInputCapture(event)
}
return event
})
a.pageRefreshFns["gateway"] = updateStatus
return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ")
}
-70
View File
@@ -1,70 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"os"
"os/exec"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func (a *App) newHomePage() tview.Primitive {
list := tview.NewList()
list.SetBorder(true).
SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
list.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)),
)
list.SetHighlightFullLine(true)
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
rebuildList := func() {
sel := list.GetCurrentItem()
list.Clear()
list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() {
a.navigateTo("schemes", a.newSchemesPage())
})
list.AddItem(
"CHANNELS: Configure communication channels",
"Manage Telegram/Discord/WeChat channels",
'n',
func() {
a.navigateTo("channels", a.newChannelsPage())
},
)
list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() {
a.navigateTo("gateway", a.newGatewayPage())
})
list.AddItem("CHAT: Start AI agent chat", "Launch interactive chat session", 'c', func() {
a.tapp.Suspend(func() {
cmd := exec.Command("picoclaw", "agent")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
})
})
list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() })
if sel >= 0 && sel < list.GetItemCount() {
list.SetCurrentItem(sel)
}
}
rebuildList()
a.pageRefreshFns["home"] = rebuildList
return a.buildShell(
"home",
list,
" [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ",
)
}
-200
View File
@@ -1,200 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
type modelsAPIResponse struct {
Data []modelEntry `json:"data"`
}
type modelEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive {
table := tview.NewTable().
SetBorders(false).
SetSelectable(true, false).
SetFixed(0, 0)
table.SetBorder(true).
SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)).
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
table.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
)
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
var modelIDs []string
status := tview.NewTextView().
SetTextAlign(tview.AlignCenter).
SetDynamicColors(true).
SetText("[#ffff00]FETCHING MODELS...[-]")
status.SetBackgroundColor(tcell.NewHexColor(0x050510))
flex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(status, 1, 0, false).
AddItem(table, 0, 1, false)
apiKey := a.resolveKey(schemeName, userName)
go func() {
var entries []modelEntry
var err error
if apiKey == "" {
err = fmt.Errorf("key is required")
} else {
entries, err = fetchModels(baseURL, apiKey)
}
a.modelCacheMu.Lock()
if a.modelCache == nil {
a.modelCache = make(map[string][]modelEntry)
}
if err == nil && len(entries) > 0 {
a.modelCache[cacheKey(schemeName, userName)] = entries
} else {
a.modelCache[cacheKey(schemeName, userName)] = nil
}
a.modelCacheMu.Unlock()
a.tapp.QueueUpdateDraw(func() {
if err != nil {
status.SetText(fmt.Sprintf("[#ff2a2a]ERROR: %s[-]", err.Error()))
table.SetCell(0, 0, tview.NewTableCell(" (failed to load models)"))
a.tapp.SetFocus(table)
return
}
if len(entries) == 0 {
status.SetText("[#ff2a2a]NO MODELS RETURNED[-]")
table.SetCell(0, 0, tview.NewTableCell(" (no models available)"))
a.tapp.SetFocus(table)
return
}
status.SetText(fmt.Sprintf("[#39ff14]%d MODEL(S) LOADED[-]", len(entries)))
for i, m := range entries {
modelIDs = append(modelIDs, m.ID)
table.SetCell(i, 0,
tview.NewTableCell(fmt.Sprintf("%3d", i+1)).
SetAlign(tview.AlignRight).
SetTextColor(tcell.NewHexColor(0x808080)).
SetSelectable(false),
)
table.SetCell(i, 1,
tview.NewTableCell(" "+m.ID).
SetAlign(tview.AlignLeft).
SetExpansion(1).
SetTextColor(tcell.NewHexColor(0xe0e0e0)),
)
}
a.tapp.SetFocus(table)
})
}()
table.SetSelectedFunc(func(row, _ int) {
if row < 0 || row >= len(modelIDs) {
return
}
a.cfg.Provider.Current = tuicfg.ProviderCurrent{
Scheme: schemeName,
User: userName,
Model: modelIDs[row],
}
a.save()
// Trigger model selected callback if set
if a.OnModelSelected != nil && a.cfg.Model.Type == "provider" {
scheme := a.cfg.Provider.SchemeByName(schemeName)
if scheme == nil {
a.goBack()
return
}
var user tuicfg.User
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == userName {
user = u
break
}
}
a.OnModelSelected(*scheme, user, modelIDs[row])
}
a.goBack()
})
return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ")
}
func (a *App) resolveKey(schemeName, userName string) string {
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == userName {
return u.Key
}
}
return ""
}
func fetchModels(baseURL, apiKey string) ([]modelEntry, error) {
url := strings.TrimRight(baseURL, "/") + "/models"
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
var result modelsAPIResponse
if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 {
return result.Data, nil
}
var arr []modelEntry
if err := json.Unmarshal(body, &arr); err == nil {
return arr, nil
}
return nil, fmt.Errorf(
"decode response: unrecognized shape: %s",
strings.TrimSpace(string(body[:min(len(body), 256)])),
)
}
-252
View File
@@ -1,252 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
func (a *App) newSchemesPage() tview.Primitive {
table := tview.NewTable().
SetBorders(false).
SetSelectable(true, false)
table.SetBorder(true).
SetTitle(" [#00f0ff::b] PROVIDER SCHEMES ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
table.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
)
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
rowToIdx := func(row int) int { return row / 2 }
selectedSchemeName := func() string {
row, _ := table.GetSelection()
idx := rowToIdx(row)
schemes := a.cfg.Provider.Schemes
if idx >= 0 && idx < len(schemes) {
return schemes[idx].Name
}
return ""
}
rebuild := func() {
selName := selectedSchemeName()
table.Clear()
schemes := a.cfg.Provider.Schemes
for i, s := range schemes {
nameRow := i * 2
detailRow := nameRow + 1
table.SetCell(nameRow, 0,
tview.NewTableCell(" "+s.Name).
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
SetExpansion(1).
SetSelectable(true),
)
users := a.cfg.Provider.UsersForScheme(s.Name)
n := len(users)
m := 0
for _, u := range users {
if models := a.cachedModels(s.Name, u.Name); len(models) > 0 {
m++
}
}
table.SetCell(detailRow, 0,
tview.NewTableCell(fmt.Sprintf(" [#808080](%d/%d) %s", m, n, s.BaseURL)).
SetTextColor(tcell.NewHexColor(0x808080)).
SetExpansion(1).
SetSelectable(false),
)
table.SetCell(detailRow, 1,
tview.NewTableCell("[#00f0ff]"+s.Type+" ").
SetAlign(tview.AlignRight).
SetSelectable(false),
)
}
if selName != "" {
for i, s := range schemes {
if s.Name == selName {
table.Select(i*2, 0)
return
}
}
}
if table.GetRowCount() > 0 {
table.Select(0, 0)
}
}
rebuild()
a.refreshModelCache(rebuild)
a.pageRefreshFns["schemes"] = func() { a.refreshModelCache(rebuild) }
table.SetSelectedFunc(func(row, _ int) {
idx := rowToIdx(row)
schemes := a.cfg.Provider.Schemes
if idx < 0 || idx >= len(schemes) {
return
}
name := schemes[idx].Name
a.navigateTo("users", a.newUsersPage(name))
})
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
row, _ := table.GetSelection()
idx := rowToIdx(row)
schemes := a.cfg.Provider.Schemes
switch event.Rune() {
case 'a':
a.showSchemeForm(nil, func(s tuicfg.Scheme) {
a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s)
a.save()
a.refreshModelCache(rebuild)
})
return nil
case 'e':
if idx < 0 || idx >= len(schemes) {
return nil
}
origName := schemes[idx].Name
orig := schemes[idx]
a.showSchemeForm(&orig, func(s tuicfg.Scheme) {
current := a.cfg.Provider.Schemes
for i, sc := range current {
if sc.Name == origName {
a.cfg.Provider.Schemes[i] = s
break
}
}
a.save()
a.refreshModelCache(func() {
rebuild()
for i, sc := range a.cfg.Provider.Schemes {
if sc.Name == s.Name {
table.Select(i*2, 0)
break
}
}
})
})
return nil
case 'd':
if idx < 0 || idx >= len(schemes) {
return nil
}
name := schemes[idx].Name
a.confirmDelete(fmt.Sprintf("scheme %q", name), func() {
current := a.cfg.Provider.Schemes
newSchemes := make([]tuicfg.Scheme, 0, len(current))
for _, sc := range current {
if sc.Name != name {
newSchemes = append(newSchemes, sc)
}
}
a.cfg.Provider.Schemes = newSchemes
existing := a.cfg.Provider.Users
filtered := make([]tuicfg.User, 0, len(existing))
for _, u := range existing {
if u.Scheme != name {
filtered = append(filtered, u)
}
}
a.cfg.Provider.Users = filtered
a.save()
a.refreshModelCache(rebuild)
})
return nil
}
return event
})
return a.buildShell(
"schemes",
table,
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ",
)
}
func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) {
name := ""
baseURL := ""
schemeType := "openai-compatible"
title := " ADD SCHEME "
if existing != nil {
name = existing.Name
baseURL = existing.BaseURL
schemeType = existing.Type
title = " EDIT SCHEME "
}
typeOptions := []string{"openai-compatible", "anthropic"}
typeIdx := 0
for i, t := range typeOptions {
if t == schemeType {
typeIdx = i
break
}
}
form := tview.NewForm()
form.
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
AddInputField("Base URL", baseURL, 28, nil, func(text string) { baseURL = text }).
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }).
AddButton("SAVE", func() {
if name == "" {
a.showError("Name is required")
return
}
if baseURL == "" {
a.showError("Base URL is required")
return
}
if existing == nil {
for _, s := range a.cfg.Provider.Schemes {
if s.Name == name {
a.showError(fmt.Sprintf("Scheme name %q already exists", name))
return
}
}
}
a.hideModal("scheme-form")
onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType})
}).
AddButton("CANCEL", func() {
a.hideModal("scheme-form")
})
form.SetBorder(true).
SetTitle(" [::b]" + title + " ").
SetTitleColor(tcell.NewHexColor(0x39ff14)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
a.hideModal("scheme-form")
return nil
}
return event
})
a.showModal("scheme-form", centeredForm(form, 4, 12))
}
-261
View File
@@ -1,261 +0,0 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
func (a *App) newUsersPage(schemeName string) tview.Primitive {
table := tview.NewTable().
SetBorders(false).
SetSelectable(true, false)
table.SetBorder(true).
SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)).
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
table.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
)
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
visibleUsers := func() []tuicfg.User {
var out []tuicfg.User
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName {
out = append(out, u)
}
}
return out
}
findUserGlobalIdx := func(userName string) int {
for i, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == userName {
return i
}
}
return -1
}
rowToVisIdx := func(row int) int { return row / 2 }
selectedUserName := func() string {
row, _ := table.GetSelection()
users := visibleUsers()
visIdx := rowToVisIdx(row)
if visIdx >= 0 && visIdx < len(users) {
return users[visIdx].Name
}
return ""
}
rebuild := func() {
selName := selectedUserName()
table.Clear()
users := visibleUsers()
for i, u := range users {
nameRow := i * 2
detailRow := nameRow + 1
table.SetCell(nameRow, 0,
tview.NewTableCell(" "+u.Name).
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
SetExpansion(1).
SetSelectable(true),
)
table.SetCell(nameRow, 1,
tview.NewTableCell("").
SetSelectable(false),
)
models := a.cachedModels(schemeName, u.Name)
var detailText string
if len(models) > 0 {
detailText = fmt.Sprintf(" [#39ff14]%d models available[-]", len(models))
} else {
detailText = " [#ff2a2a]Inactive / No Access[-]"
}
table.SetCell(detailRow, 0,
tview.NewTableCell(detailText).
SetTextColor(tcell.NewHexColor(0x808080)).
SetExpansion(1).
SetSelectable(false),
)
table.SetCell(detailRow, 1,
tview.NewTableCell("[#00f0ff]"+u.Type+" ").
SetAlign(tview.AlignRight).
SetSelectable(false),
)
}
if selName != "" {
for i, u := range users {
if u.Name == selName {
table.Select(i*2, 0)
return
}
}
}
if table.GetRowCount() > 0 {
table.Select(0, 0)
}
}
rebuild()
a.refreshModelCache(rebuild)
a.pageRefreshFns["users"] = func() { a.refreshModelCache(rebuild) }
table.SetSelectedFunc(func(row, _ int) {
visIdx := rowToVisIdx(row)
users := visibleUsers()
if visIdx < 0 || visIdx >= len(users) {
return
}
uName := users[visIdx].Name
scheme := a.cfg.Provider.SchemeByName(schemeName)
if scheme == nil {
a.showError(fmt.Sprintf("Scheme %q not found", schemeName))
return
}
a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL))
})
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
row, _ := table.GetSelection()
visIdx := rowToVisIdx(row)
users := visibleUsers()
switch event.Rune() {
case 'a':
a.showUserForm(schemeName, nil, func(u tuicfg.User) {
a.cfg.Provider.Users = append(a.cfg.Provider.Users, u)
a.save()
a.refreshModelCache(rebuild)
})
return nil
case 'e':
if visIdx < 0 || visIdx >= len(users) {
return nil
}
origName := users[visIdx].Name
orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)]
a.showUserForm(schemeName, &orig, func(u tuicfg.User) {
cfgIdx := findUserGlobalIdx(origName)
if cfgIdx < 0 {
a.showError(fmt.Sprintf("User %q no longer exists", origName))
return
}
a.cfg.Provider.Users[cfgIdx] = u
a.save()
a.refreshModelCache(func() {
rebuild()
for i, usr := range visibleUsers() {
if usr.Name == u.Name {
table.Select(i*2, 0)
break
}
}
})
})
return nil
case 'd':
if visIdx < 0 || visIdx >= len(users) {
return nil
}
uName := users[visIdx].Name
a.confirmDelete(fmt.Sprintf("user %q", uName), func() {
cfgIdx := findUserGlobalIdx(uName)
if cfgIdx < 0 {
return
}
all := a.cfg.Provider.Users
a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...)
a.save()
a.refreshModelCache(rebuild)
})
return nil
}
return event
})
return a.buildShell(
"users",
table,
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ",
)
}
func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) {
name := ""
userType := "key"
key := ""
title := " ADD USER "
if existing != nil {
name = existing.Name
userType = existing.Type
key = existing.Key
title = " EDIT USER "
}
typeOptions := []string{"key", "OAuth"}
typeIdx := 0
for i, t := range typeOptions {
if t == userType {
typeIdx = i
break
}
}
form := tview.NewForm()
form.
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }).
AddPasswordField("Key", key, 28, '*', func(text string) { key = text }).
AddButton("SAVE", func() {
if name == "" {
a.showError("Name is required")
return
}
if existing == nil {
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == name {
a.showError(fmt.Sprintf("User name %q already exists for this scheme", name))
return
}
}
}
a.hideModal("user-form")
onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key})
}).
AddButton("CANCEL", func() {
a.hideModal("user-form")
})
form.SetBorder(true).
SetTitle(" [::b]" + title + " ").
SetTitleColor(tcell.NewHexColor(0x39ff14)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
a.hideModal("user-form")
return nil
}
return event
})
a.showModal("user-form", centeredForm(form, 4, 13))
}
+200
View File
@@ -0,0 +1,200 @@
package model
import (
"bufio"
"fmt"
"io"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
)
const defaultAliasName = "custom-prefer"
func newAddCommand() *cobra.Command {
var (
apiBase string
apiKey string
modelID string
alias string
modelType string
)
cmd := &cobra.Command{
Use: "add",
Short: "Add a model from an OpenAI-compatible endpoint",
Long: `Add a model entry by querying an OpenAI-compatible endpoint exposing
GET <api-base>/models, then setting it as the default model.
If --model is omitted, the available models are listed and you can pick one
interactively. If --model is provided, the entry is written without contacting
the server.
Sample interactive session (key shown masked):
$ picoclaw model add \
-b https://ark.cn-beijing.volces.com/api/v3 \
-k 7dff****-****-****-****-********e829
115 model(s) available:
1) doubao-lite-128k-240428 (doubao-lite-128k)
2) doubao-pro-128k-240515 (doubao-pro-128k)
...
48) deepseek-r1-250120 (deepseek-r1)
78) kimi-k2-250711 (kimi-k2)
...
115) doubao-seed3d-2-0-260328 (doubao-seed3d-2-0)
Pick a model (number or id): 48
✓ Saved model 'custom-prefer' (deepseek-r1-250120) and set as default.`,
Example: ` picoclaw model add --api-base https://api.openai.com/v1 --api-key sk-...
picoclaw model add -b http://localhost:8000/v1 -k dummy -m my-model -n local`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return runAdd(addOptions{
apiBase: strings.TrimSpace(apiBase),
apiKey: strings.TrimSpace(apiKey),
modelID: strings.TrimSpace(modelID),
alias: strings.TrimSpace(alias),
modelType: strings.TrimSpace(modelType),
stdin: cmd.InOrStdin(),
stdout: cmd.OutOrStdout(),
})
},
}
cmd.Flags().StringVarP(&apiBase, "api-base", "b", "",
"API base URL (required), e.g. https://api.openai.com/v1")
cmd.Flags().StringVarP(&apiKey, "api-key", "k", "", "API key (required)")
cmd.Flags().StringVarP(&modelID, "model", "m", "",
"Model id; when set, skips the interactive picker and the network call")
cmd.Flags().StringVarP(&alias, "name", "n", defaultAliasName,
"Local alias written to model_list and used as the default model name")
cmd.Flags().StringVar(&modelType, "type", "openai-compatible",
"Endpoint type (only 'openai-compatible' is supported today)")
_ = cmd.MarkFlagRequired("api-base")
_ = cmd.MarkFlagRequired("api-key")
return cmd
}
type addOptions struct {
apiBase string
apiKey string
modelID string
alias string
modelType string
stdin io.Reader
stdout io.Writer
}
func runAdd(opt addOptions) error {
if opt.modelType != "" && opt.modelType != "openai-compatible" {
return fmt.Errorf("unsupported --type %q (only 'openai-compatible' is supported)", opt.modelType)
}
if opt.alias == "" {
opt.alias = defaultAliasName
}
selected := opt.modelID
if selected == "" {
entries, err := fetchOpenAIModels(opt.apiBase, opt.apiKey)
if err != nil {
return fmt.Errorf("fetch models: %w", err)
}
if len(entries) == 0 {
return fmt.Errorf("no models returned by %s", opt.apiBase)
}
selected, err = pickModel(opt.stdin, opt.stdout, entries)
if err != nil {
return err
}
}
return upsertModelDefault(opt.apiBase, opt.apiKey, opt.alias, selected, opt.stdout)
}
func pickModel(stdin io.Reader, stdout io.Writer, entries []modelEntry) (string, error) {
fmt.Fprintf(stdout, "\n%d model(s) available:\n", len(entries))
for i, m := range entries {
line := m.ID
if m.Name != "" && m.Name != m.ID {
line = fmt.Sprintf("%s (%s)", m.ID, m.Name)
}
fmt.Fprintf(stdout, " %3d) %s\n", i+1, line)
}
scanner := bufio.NewScanner(stdin)
for {
fmt.Fprint(stdout, "Pick a model (number or id): ")
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("read input: %w", err)
}
return "", fmt.Errorf("no selection provided")
}
text := strings.TrimSpace(scanner.Text())
if text == "" {
continue
}
if idx, err := strconv.Atoi(text); err == nil {
if idx < 1 || idx > len(entries) {
fmt.Fprintf(stdout, "Out of range. Enter 1-%d.\n", len(entries))
continue
}
return entries[idx-1].ID, nil
}
for _, m := range entries {
if m.ID == text {
return m.ID, nil
}
}
fmt.Fprintln(stdout, "Not a valid number or model id; try again.")
}
}
func upsertModelDefault(apiBase, apiKey, alias, modelID string, stdout io.Writer) error {
configPath := internal.GetConfigPath()
cfg, err := config.LoadConfig(configPath)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
secureKeys := config.SimpleSecureStrings(apiKey)
found := false
for _, m := range cfg.ModelList {
if m == nil {
continue
}
if m.ModelName == alias {
m.Model = modelID
m.APIBase = apiBase
m.APIKeys = secureKeys
m.Enabled = true
found = true
break
}
}
if !found {
cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{
ModelName: alias,
Model: modelID,
APIBase: apiBase,
APIKeys: secureKeys,
Enabled: true,
})
}
cfg.Agents.Defaults.ModelName = alias
if err := config.SaveConfig(configPath, cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Fprintf(stdout, "✓ Saved model '%s' (%s) and set as default.\n", alias, modelID)
return nil
}
+257
View File
@@ -0,0 +1,257 @@
package model
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestFetchOpenAIModels_DataEnvelope(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/models", r.URL.Path)
assert.Equal(t, "Bearer secret", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-foo","name":"Foo"},{"id":"gpt-bar"}]}`))
}))
defer srv.Close()
entries, err := fetchOpenAIModels(srv.URL, "secret")
require.NoError(t, err)
require.Len(t, entries, 2)
assert.Equal(t, "gpt-foo", entries[0].ID)
assert.Equal(t, "Foo", entries[0].Name)
assert.Equal(t, "gpt-bar", entries[1].ID)
}
func TestFetchOpenAIModels_BareArray(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"id":"a"},{"id":"b"}]`))
}))
defer srv.Close()
entries, err := fetchOpenAIModels(srv.URL, "secret")
require.NoError(t, err)
require.Len(t, entries, 2)
assert.Equal(t, "a", entries[0].ID)
assert.Equal(t, "b", entries[1].ID)
}
func TestFetchOpenAIModels_TrimsTrailingSlash(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_, _ = w.Write([]byte(`{"data":[{"id":"x"}]}`))
}))
defer srv.Close()
_, err := fetchOpenAIModels(srv.URL+"/", "k")
require.NoError(t, err)
assert.Equal(t, "/models", gotPath)
}
func TestFetchOpenAIModels_HTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "nope", http.StatusUnauthorized)
}))
defer srv.Close()
_, err := fetchOpenAIModels(srv.URL, "bad")
require.Error(t, err)
assert.Contains(t, err.Error(), "HTTP 401")
}
func TestFetchOpenAIModels_EmptyDataEnvelope(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data":[]}`))
}))
defer srv.Close()
entries, err := fetchOpenAIModels(srv.URL, "k")
require.NoError(t, err)
assert.Empty(t, entries)
}
func TestFetchOpenAIModels_EmptyBareArray(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
entries, err := fetchOpenAIModels(srv.URL, "k")
require.NoError(t, err)
assert.Empty(t, entries)
}
func TestFetchOpenAIModels_UnrecognizedShape(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"models":"not-supported"}`))
}))
defer srv.Close()
_, err := fetchOpenAIModels(srv.URL, "k")
require.Error(t, err)
assert.Contains(t, err.Error(), "unrecognized shape")
}
func TestFetchOpenAIModels_RequiresInputs(t *testing.T) {
_, err := fetchOpenAIModels("", "k")
require.Error(t, err)
assert.Contains(t, err.Error(), "api base")
_, err = fetchOpenAIModels("https://example.com", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "api key")
}
func TestPickModel_ByIndex(t *testing.T) {
entries := []modelEntry{{ID: "a"}, {ID: "b"}, {ID: "c"}}
out := &bytes.Buffer{}
got, err := pickModel(strings.NewReader("2\n"), out, entries)
require.NoError(t, err)
assert.Equal(t, "b", got)
assert.Contains(t, out.String(), "3 model(s) available")
}
func TestPickModel_ByID(t *testing.T) {
entries := []modelEntry{{ID: "alpha"}, {ID: "beta"}}
out := &bytes.Buffer{}
got, err := pickModel(strings.NewReader("beta\n"), out, entries)
require.NoError(t, err)
assert.Equal(t, "beta", got)
}
func TestPickModel_RetriesOnInvalid(t *testing.T) {
entries := []modelEntry{{ID: "x"}}
out := &bytes.Buffer{}
got, err := pickModel(strings.NewReader("\n9\nnot-a-model\nx\n"), out, entries)
require.NoError(t, err)
assert.Equal(t, "x", got)
rendered := out.String()
assert.Contains(t, rendered, "Out of range")
assert.Contains(t, rendered, "Not a valid number")
}
func TestRunAdd_WithExplicitModel_NoNetwork(t *testing.T) {
initTest(t)
out := &bytes.Buffer{}
err := runAdd(addOptions{
apiBase: "https://invalid.invalid/v1",
apiKey: "k",
modelID: "explicit-model",
alias: "myalias",
modelType: "openai-compatible",
stdout: out,
})
require.NoError(t, err)
assert.Contains(t, out.String(), "Saved model 'myalias' (explicit-model)")
cfg, err := config.LoadConfig(configPath)
require.NoError(t, err)
assert.Equal(t, "myalias", cfg.Agents.Defaults.GetModelName())
added := findModelByName(cfg, "myalias")
require.NotNil(t, added, "expected model 'myalias' in model_list")
assert.Equal(t, "explicit-model", added.Model)
assert.Equal(t, "https://invalid.invalid/v1", added.APIBase)
assert.True(t, added.Enabled)
require.Len(t, added.APIKeys, 1)
assert.Equal(t, "k", added.APIKeys[0].String())
}
func findModelByName(cfg *config.Config, name string) *config.ModelConfig {
for _, m := range cfg.ModelList {
if m != nil && m.ModelName == name {
return m
}
}
return nil
}
func TestRunAdd_FetchAndPick(t *testing.T) {
initTest(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Bearer my-key", r.Header.Get("Authorization"))
_, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`))
}))
defer srv.Close()
out := &bytes.Buffer{}
err := runAdd(addOptions{
apiBase: srv.URL,
apiKey: "my-key",
alias: defaultAliasName,
modelType: "openai-compatible",
stdin: strings.NewReader("2\n"),
stdout: out,
})
require.NoError(t, err)
cfg, err := config.LoadConfig(configPath)
require.NoError(t, err)
assert.Equal(t, defaultAliasName, cfg.Agents.Defaults.GetModelName())
added := findModelByName(cfg, defaultAliasName)
require.NotNil(t, added)
assert.Equal(t, "m2", added.Model)
}
func TestRunAdd_UpsertsExistingAlias(t *testing.T) {
initTest(t)
first := &bytes.Buffer{}
require.NoError(t, runAdd(addOptions{
apiBase: "https://a.example/v1",
apiKey: "k1",
modelID: "m1",
alias: "shared",
stdout: first,
}))
second := &bytes.Buffer{}
require.NoError(t, runAdd(addOptions{
apiBase: "https://b.example/v1",
apiKey: "k2",
modelID: "m2",
alias: "shared",
stdout: second,
}))
cfg, err := config.LoadConfig(configPath)
require.NoError(t, err)
matches := 0
for _, m := range cfg.ModelList {
if m != nil && m.ModelName == "shared" {
matches++
}
}
assert.Equal(t, 1, matches, "alias should be updated, not duplicated")
updated := findModelByName(cfg, "shared")
require.NotNil(t, updated)
assert.Equal(t, "m2", updated.Model)
assert.Equal(t, "https://b.example/v1", updated.APIBase)
assert.Equal(t, "k2", updated.APIKeys[0].String())
}
func TestRunAdd_RejectsUnsupportedType(t *testing.T) {
initTest(t)
err := runAdd(addOptions{
apiBase: "https://x/v1",
apiKey: "k",
modelID: "m",
alias: "a",
modelType: "anthropic",
stdout: &bytes.Buffer{},
})
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported --type")
}
+11
View File
@@ -21,11 +21,17 @@ func NewModelCommand() *cobra.Command {
If no argument is provided, shows the current default model.
If a model name is provided, sets it as the default model.
To onboard a model from a custom OpenAI-compatible endpoint (fetch the
available list online and pick one), use the 'add' subcommand:
picoclaw model add --help
Examples:
picoclaw model # Show current default model
picoclaw model gpt-5.2 # Set gpt-5.2 as default
picoclaw model claude-sonnet-4.6 # Set claude-sonnet-4.6 as default
picoclaw model local-model # Set local VLLM server as default
picoclaw model add -b URL -k KEY # Add a model from a custom endpoint
Note: 'local-model' is a special value for using a local VLLM server
(running at localhost:8000 by default) which does not require an API key.`,
@@ -51,6 +57,8 @@ Note: 'local-model' is a special value for using a local VLLM server
},
}
cmd.AddCommand(newAddCommand())
return cmd
}
@@ -66,6 +74,9 @@ func showCurrentModel(cfg *config.Config) {
fmt.Println("\nAvailable models in your config:")
listAvailableModels(cfg)
}
fmt.Println("\nTip: 'picoclaw model add -b URL -k KEY' adds a model from a custom")
fmt.Println(" OpenAI-compatible endpoint (see 'picoclaw model add --help').")
}
func listAvailableModels(cfg *config.Config) {
+77
View File
@@ -0,0 +1,77 @@
package model
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type modelEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
type modelsAPIResponse struct {
Data []modelEntry `json:"data"`
}
// fetchOpenAIModels GETs <baseURL>/models with Bearer auth and accepts both the
// {data:[…]} envelope and a bare array shape used by various OpenAI-compatible servers.
func fetchOpenAIModels(baseURL, apiKey string) ([]modelEntry, error) {
if strings.TrimSpace(baseURL) == "" {
return nil, fmt.Errorf("api base is required")
}
if strings.TrimSpace(apiKey) == "" {
return nil, fmt.Errorf("api key is required")
}
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)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
// {"data": [...]} envelope. Distinguish "envelope shape with empty list"
// from "object without a data key" via Data being non-nil after unmarshal:
// json.Unmarshal sets Data to []modelEntry{} for `{"data":[]}` but leaves
// it as nil when "data" is absent or null.
var envelope modelsAPIResponse
if err := json.Unmarshal(body, &envelope); err == nil && envelope.Data != nil {
return envelope.Data, nil
}
// Bare-array shape, including `[]`.
var arr []modelEntry
if err := json.Unmarshal(body, &arr); err == nil {
return arr, nil
}
preview := body
if len(preview) > 256 {
preview = preview[:256]
}
return nil, fmt.Errorf("decode response: unrecognized shape: %s", strings.TrimSpace(string(preview)))
}
-1
View File
@@ -6,7 +6,6 @@ RUN apk add --no-cache ca-certificates tzdata
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher
COPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui
ENTRYPOINT ["picoclaw-launcher"]
CMD ["-console", "-public", "-no-browser"]
+1 -1
View File
@@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
### Mode Launcher (Console Web)
L'image `launcher` inclut les trois binaires (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) et démarre la console web par défaut, qui fournit une interface navigateur pour la configuration et le chat.
L'image `launcher` inclut les deux binaires (`picoclaw`, `picoclaw-launcher`) et démarre la console web par défaut, qui fournit une interface navigateur pour la configuration et le chat.
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
+1 -1
View File
@@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
### Launcher モード (Web コンソール)
`launcher` イメージには 3 つのバイナリ(`picoclaw``picoclaw-launcher``picoclaw-launcher-tui`)がすべて含まれており、デフォルトで Web コンソールを起動します。ブラウザベースの設定・チャット画面を提供します。
`launcher` イメージには 2 つのバイナリ(`picoclaw``picoclaw-launcher`)が含まれており、デフォルトで Web コンソールを起動します。ブラウザベースの設定・チャット画面を提供します。
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
+1 -1
View File
@@ -39,7 +39,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
### Launcher Mode (Web Console)
The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat.
The `launcher` image includes both binaries (`picoclaw`, `picoclaw-launcher`) and starts the web console by default, which provides a browser-based UI for configuration and chat.
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
+1 -1
View File
@@ -35,7 +35,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
### Mod Launcher (Konsol Web)
Imej `launcher` merangkumi ketiga-tiga binari (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) dan memulakan konsol web secara lalai, yang menyediakan UI berasaskan pelayar untuk konfigurasi dan sembang.
Imej `launcher` merangkumi kedua-dua binari (`picoclaw`, `picoclaw-launcher`) dan memulakan konsol web secara lalai, yang menyediakan UI berasaskan pelayar untuk konfigurasi dan sembang.
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
+1 -1
View File
@@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
### Modo Launcher (Console Web)
A imagem `launcher` inclui os três binários (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) e inicia o console web por padrão, que fornece uma interface baseada em navegador para configuração e chat.
A imagem `launcher` inclui ambos os binários (`picoclaw`, `picoclaw-launcher`) e inicia o console web por padrão, que fornece uma interface baseada em navegador para configuração e chat.
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
+1 -1
View File
@@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
### Chế Độ Launcher (Web Console)
Image `launcher` bao gồm cả ba binary (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) và khởi động web console mặc định, cung cấp giao diện trình duyệt để cấu hình và chat.
Image `launcher` bao gồm cả hai binary (`picoclaw`, `picoclaw-launcher`) và khởi động web console mặc định, cung cấp giao diện trình duyệt để cấu hình và chat.
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
+1 -1
View File
@@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down
### Launcher 模式 (Web 控制台)
`launcher` 镜像包含所有三个二进制文件(`picoclaw``picoclaw-launcher``picoclaw-launcher-tui`),默认启动 Web 控制台,提供基于浏览器的配置和聊天界面。
`launcher` 镜像包含个二进制文件(`picoclaw``picoclaw-launcher`),默认启动 Web 控制台,提供基于浏览器的配置和聊天界面。
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
-18
View File
@@ -292,24 +292,6 @@ Après cette étape unique, `picoclaw-launcher` s'ouvrira normalement lors des l
</details>
### 💻 TUI Launcher (Recommandé pour les environnements sans interface / SSH)
Le TUI (Terminal UI) Launcher fournit une interface terminal complète pour la configuration et la gestion. Idéal pour les serveurs, Raspberry Pi et autres environnements sans interface graphique.
```bash
picoclaw-launcher-tui
```
<p align="center">
<img src="../../assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
</p>
**Pour commencer :**
Utilisez les menus TUI pour : **1)** Configurer un Provider -> **2)** Configurer un Channel -> **3)** Démarrer le Gateway -> **4)** Chattez !
Pour la documentation détaillée du TUI, voir [docs.picoclaw.io](https://docs.picoclaw.io).
<a id="-run-on-old-android-phones"></a>
### 📱 Android
-18
View File
@@ -289,24 +289,6 @@ Setelah langkah satu kali ini, `picoclaw-launcher` akan terbuka secara normal pa
</details>
### 💻 TUI Launcher (Direkomendasikan untuk Headless / SSH)
TUI (Terminal UI) Launcher menyediakan antarmuka terminal lengkap untuk konfigurasi dan manajemen. Ideal untuk server, Raspberry Pi, dan lingkungan headless lainnya.
```bash
picoclaw-launcher-tui
```
<p align="center">
<img src="../../assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
</p>
**Memulai:**
Gunakan menu TUI untuk: **1)** Konfigurasi Provider -> **2)** Konfigurasi Channel -> **3)** Mulai Gateway -> **4)** Chat!
Untuk dokumentasi TUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
### 📱 Android
Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw.
-18
View File
@@ -289,24 +289,6 @@ Dopo questo passaggio una tantum, `picoclaw-launcher` si aprirà normalmente ai
</details>
### 💻 TUI Launcher (Consigliato per Headless / SSH)
Il TUI (Terminal UI) Launcher fornisce un'interfaccia terminale completa per la configurazione e la gestione. Ideale per server, Raspberry Pi e altri ambienti headless.
```bash
picoclaw-launcher-tui
```
<p align="center">
<img src="../../assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
</p>
**Per iniziare:**
Usa i menu TUI per: **1)** Configurare un Provider -> **2)** Configurare un Channel -> **3)** Avviare il Gateway -> **4)** Chattare!
Per la documentazione dettagliata del TUI, vedi [docs.picoclaw.io](https://docs.picoclaw.io).
### 📱 Android
Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw.
-18
View File
@@ -289,24 +289,6 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
</details>
### 💻 TUI Launcher(ヘッドレス / SSH 向け推奨)
TUITerminal UILauncher は設定と管理のためのフル機能ターミナルインターフェースを提供します。サーバー、Raspberry Pi、その他のヘッドレス環境に最適です。
```bash
picoclaw-launcher-tui
```
<p align="center">
<img src="../../assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
</p>
**始め方:**
TUI メニューを使って:**1)** Provider を設定 → **2)** Channel を設定 → **3)** Gateway を起動 → **4)** チャット!
TUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.io) を参照してください。
<a id="-run-on-old-android-phones"></a>
### 📱 Android
-18
View File
@@ -289,24 +289,6 @@ macOS에서는 인터넷에서 다운로드한 앱이고 Mac App Store 공증을
</details>
### 💻 TUI Launcher (헤드리스 / SSH 권장)
TUI(Terminal UI) Launcher는 설정과 관리를 위한 모든 기능을 갖춘 터미널 인터페이스를 제공합니다. 서버, Raspberry Pi, 기타 헤드리스 환경에 적합합니다.
```bash
picoclaw-launcher-tui
```
<p align="center">
<img src="../../assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
</p>
**시작 방법:**
TUI 메뉴를 사용해 다음 순서로 진행하세요. **1)** 프로바이더 설정 -> **2)** 채널 설정 -> **3)** 게이트웨이 시작 -> **4)** 채팅!
자세한 TUI 문서는 [docs.picoclaw.io](https://docs.picoclaw.io)를 참고하세요.
### 📱 Android
오래된 스마트폰에 새 생명을 불어넣어 보세요! PicoClaw를 설치하면 스마트 AI 어시스턴트로 바꿀 수 있습니다.
-18
View File
@@ -286,24 +286,6 @@ Selepas langkah sekali ini, `picoclaw-launcher` akan dibuka secara normal pada p
</details>
### 💻 Pelancar TUI (Disyorkan untuk Headless / SSH)
Pelancar TUI menyediakan antara muka terminal lengkap untuk konfigurasi dan pengurusan. Sesuai untuk pelayan, Raspberry Pi, dan persekitaran tanpa kepala lain.
```bash
picoclaw-launcher-tui
```
<p align="center">
<img src="../../assets/launcher-tui.jpg" alt="Pelancar TUI" width="600">
</p>
**Memulakan:**
Gunakan menu TUI untuk: **1)** Konfigurasikan Penyedia -> **2)** Konfigurasikan Saluran -> **3)** Mulakan Gateway -> **4)** Sembang!
Untuk dokumentasi TUI terperinci, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
### 📱 Android
Berikan telefon lama anda kehidupan baru! Jadikannya Pembantu AI pintar dengan PicoClaw.
-18
View File
@@ -289,24 +289,6 @@ Após esta etapa única, o `picoclaw-launcher` abrirá normalmente nos lançamen
</details>
### 💻 TUI Launcher (Recomendado para Headless / SSH)
O TUI (Terminal UI) Launcher fornece uma interface de terminal completa para configuração e gerenciamento. Ideal para servidores, Raspberry Pi e outros ambientes headless.
```bash
picoclaw-launcher-tui
```
<p align="center">
<img src="../../assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
</p>
**Primeiros passos:**
Use os menus do TUI para: **1)** Configurar um Provider -> **2)** Configurar um Channel -> **3)** Iniciar o Gateway -> **4)** Conversar!
Para documentação detalhada do TUI, veja [docs.picoclaw.io](https://docs.picoclaw.io).
<a id="-run-on-old-android-phones"></a>
### 📱 Android
-18
View File
@@ -289,24 +289,6 @@ Sau bước này, `picoclaw-launcher` sẽ mở bình thường trong các lần
</details>
### 💻 TUI Launcher (Khuyến nghị cho Headless / SSH)
TUI (Terminal UI) Launcher cung cấp giao diện terminal đầy đủ tính năng để cấu hình và quản lý. Lý tưởng cho máy chủ, Raspberry Pi và các môi trường headless khác.
```bash
picoclaw-launcher-tui
```
<p align="center">
<img src="../../assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
</p>
**Bắt đầu:**
Sử dụng menu TUI để: **1)** Cấu hình Provider -> **2)** Cấu hình Channel -> **3)** Khởi động Gateway -> **4)** Trò chuyện!
Để biết tài liệu TUI chi tiết, xem [docs.picoclaw.io](https://docs.picoclaw.io).
<a id="-run-on-old-android-phones"></a>
### 📱 Android
-18
View File
@@ -289,24 +289,6 @@ macOS 可能会在首次启动时拦截 `picoclaw-launcher`,因为它从互联
</details>
### 💻 TUI Launcher(推荐无头环境 / SSH
TUI(终端 UI)Launcher 提供功能完整的终端配置与管理界面,适合服务器、树莓派等无显示器环境。
```bash
picoclaw-launcher-tui
```
<p align="center">
<img src="../../assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
</p>
**开始使用:**
通过 TUI 菜单:**1)** 配置 Provider -> **2)** 配置 Channel -> **3)** 启动 Gateway -> **4)** 开始聊天!
详细 TUI 文档请参阅 [docs.picoclaw.io](https://docs.picoclaw.io)。
<a id="-run-on-old-android-phones"></a>
### 📱 Android
+1 -5
View File
@@ -4,7 +4,6 @@ go 1.25.9
require (
fyne.io/systray v1.12.0
github.com/BurntSushi/toml v1.6.0
github.com/SevereCloud/vksdk/v3 v3.3.1
github.com/adhocore/gronx v1.19.6
github.com/anthropics/anthropic-sdk-go v1.26.0
@@ -18,7 +17,6 @@ require (
github.com/creack/pty v1.1.24
github.com/ergochat/irc-go v0.6.0
github.com/ergochat/readline v0.1.3
github.com/gdamore/tcell/v2 v2.13.8
github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
@@ -33,7 +31,6 @@ require (
github.com/openai/openai-go/v3 v3.22.0
github.com/pion/rtp v1.10.1
github.com/pion/webrtc/v3 v3.3.6
github.com/rivo/tview v0.42.0
github.com/rs/zerolog v1.35.1
github.com/slack-go/slack v0.17.3
github.com/spf13/cobra v1.10.2
@@ -79,7 +76,6 @@ 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/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
@@ -122,7 +118,7 @@ require (
github.com/github/copilot-sdk/go v0.2.0
github.com/go-resty/resty/v2 v2.17.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/jsonschema-go v0.4.2
github.com/grbit/go-json v0.11.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
-8
View File
@@ -5,8 +5,6 @@ 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/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg=
@@ -105,10 +103,6 @@ github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4p
github.com/ergochat/readline v0.1.3/go.mod h1:o3ux9QLHLm77bq7hDB21UTm6HlV2++IPDMfIfKDuOgY=
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.2.0 h1:RnrIIirmtp4wGgqSQFJ2k9phbeveIxOtYZqDogoNEa0=
github.com/github/copilot-sdk/go v0.2.0/go.mod h1:uGWkjVYcp2DV9DgtqYihh5tEoJjNqxIFaUNnrwY4FxM=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -232,8 +226,6 @@ 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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=