refactor: enhance TUI configuration and user management with improved UI elements and concurrency

This commit is contained in:
taorye 2026-03-20 15:39:15 +08:00
parent 5a199ec993
commit 119cc2e8e1
No known key found for this signature in database
GPG key ID: EDFFE0AD344F5FE0
6 changed files with 545 additions and 147 deletions

View file

@ -95,6 +95,9 @@ func Load(path string) (*TUIConfig, error) {
// Save writes cfg to path atomically (safe for flash / SD storage). // Save writes cfg to path atomically (safe for flash / SD storage).
func Save(path string, cfg *TUIConfig) error { 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 var buf bytes.Buffer
enc := toml.NewEncoder(&buf) enc := toml.NewEncoder(&buf)
if err := enc.Encode(cfg); err != nil { if err := enc.Encode(cfg); err != nil {

View file

@ -6,6 +6,9 @@
package ui package ui
import ( import (
"fmt"
"sync"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"github.com/rivo/tview" "github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
@ -13,26 +16,119 @@ import (
// App is the root TUI application. // App is the root TUI application.
type App struct { type App struct {
tapp *tview.Application tapp *tview.Application
pages *tview.Pages pages *tview.Pages
pageStack []string pageStack []string
cfg *tuicfg.TUIConfig cfg *tuicfg.TUIConfig
configPath string configPath string
homeRefreshFn func() pageRefreshFns map[string]func()
headerModelTV *tview.TextView
modalOpen map[string]bool
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)
u := u
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. // New creates and wires up the TUI application.
func New(cfg *tuicfg.TUIConfig, configPath string) *App { func New(cfg *tuicfg.TUIConfig, configPath string) *App {
tview.Styles.PrimitiveBackgroundColor = tcell.ColorBlack
tview.Styles.ContrastBackgroundColor = tcell.ColorTeal
tview.Styles.MoreContrastBackgroundColor = tcell.ColorLime
tview.Styles.BorderColor = tcell.ColorDarkCyan
tview.Styles.TitleColor = tcell.ColorAqua
tview.Styles.GraphicsColor = tcell.ColorDarkCyan
tview.Styles.PrimaryTextColor = tcell.ColorWhite
tview.Styles.SecondaryTextColor = tcell.ColorSilver
tview.Styles.TertiaryTextColor = tcell.ColorAqua
tview.Styles.InverseTextColor = tcell.ColorBlack
tview.Styles.ContrastSecondaryTextColor = tcell.ColorNavy
a := &App{ a := &App{
tapp: tview.NewApplication(), tapp: tview.NewApplication(),
pages: tview.NewPages(), pages: tview.NewPages(),
pageStack: []string{}, pageStack: []string{},
cfg: cfg, cfg: cfg,
configPath: configPath, configPath: configPath,
pageRefreshFns: make(map[string]func()),
modalOpen: make(map[string]bool),
} }
a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape { if event.Key() == tcell.KeyEscape {
if len(a.modalOpen) > 0 {
return nil
}
return a.goBack() return a.goBack()
} }
return event return event
@ -53,6 +149,7 @@ func (a *App) buildPages() {
} }
func (a *App) navigateTo(name string, page tview.Primitive) { func (a *App) navigateTo(name string, page tview.Primitive) {
a.pages.RemovePage(name)
a.pages.AddPage(name, page, true, false) a.pages.AddPage(name, page, true, false)
a.pageStack = append(a.pageStack, name) a.pageStack = append(a.pageStack, name)
a.pages.SwitchToPage(name) a.pages.SwitchToPage(name)
@ -62,26 +159,35 @@ func (a *App) goBack() *tcell.EventKey {
if len(a.pageStack) <= 1 { if len(a.pageStack) <= 1 {
return nil return nil
} }
popped := a.pageStack[len(a.pageStack)-1]
a.pageStack = 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] prev := a.pageStack[len(a.pageStack)-1]
if prev == "home" && a.homeRefreshFn != nil { if fn, ok := a.pageRefreshFns[prev]; ok {
a.homeRefreshFn() fn()
}
if prev == "home" && a.headerModelTV != nil {
a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ")
} }
a.pages.SwitchToPage(prev) a.pages.SwitchToPage(prev)
return nil return nil
} }
func (a *App) showModal(name string, primitive tview.Primitive) { func (a *App) showModal(name string, primitive tview.Primitive) {
a.modalOpen[name] = true
a.pages.AddPage(name, primitive, true, true) a.pages.AddPage(name, primitive, true, true)
} }
func (a *App) hideModal(name string) { func (a *App) hideModal(name string) {
delete(a.modalOpen, name)
a.pages.HidePage(name) a.pages.HidePage(name)
a.pages.RemovePage(name) a.pages.RemovePage(name)
} }
func (a *App) save() { func (a *App) save() {
_ = tuicfg.Save(a.configPath, a.cfg) if err := tuicfg.Save(a.configPath, a.cfg); err != nil {
a.showError("save failed: " + err.Error())
}
} }
func (a *App) showError(msg string) { func (a *App) showError(msg string) {
@ -91,6 +197,10 @@ func (a *App) showError(msg string) {
SetDoneFunc(func(_ int, _ string) { SetDoneFunc(func(_ int, _ string) {
a.hideModal("error") a.hideModal("error")
}) })
modal.SetBackgroundColor(tcell.ColorNavy)
modal.SetTextColor(tcell.ColorWhite)
modal.SetButtonBackgroundColor(tcell.ColorDarkCyan)
modal.SetButtonTextColor(tcell.ColorWhite)
a.showModal("error", modal) a.showModal("error", modal)
} }
@ -104,20 +214,99 @@ func (a *App) confirmDelete(label string, onConfirm func()) {
onConfirm() onConfirm()
} }
}) })
modal.SetBackgroundColor(tcell.ColorNavy)
modal.SetTextColor(tcell.ColorWhite)
modal.SetButtonBackgroundColor(tcell.ColorDarkCyan)
modal.SetButtonTextColor(tcell.ColorWhite)
a.showModal("confirm-delete", modal) a.showModal("confirm-delete", modal)
} }
func centeredForm(form *tview.Form, width, height int) tview.Primitive { func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive {
return tview.NewGrid(). return tview.NewFlex().
SetColumns(0, width, 0). AddItem(tview.NewBox(), 0, 1, false).
SetRows(0, height, 0). AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(form, 1, 1, 1, 1, 0, 0, true) 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 { func hintBar(text string) *tview.TextView {
tv := tview.NewTextView(). tv := tview.NewTextView().
SetText(text). SetText(text).
SetTextAlign(tview.AlignCenter) SetTextAlign(tview.AlignCenter).
tv.SetBackgroundColor(tcell.ColorDarkBlue) SetTextColor(tcell.ColorAqua)
tv.SetBackgroundColor(tcell.ColorMidnightBlue)
return tv 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.ColorYellow).
SetDynamicColors(true).
SetBackgroundColor(tcell.ColorBlack)
}
modelTV = a.headerModelTV
modelTV.SetText(a.cfg.CurrentModelLabel() + " ")
} else {
modelTV = tview.NewTextView()
modelTV.SetBackgroundColor(tcell.ColorBlack)
}
headerLeft := tview.NewTextView().
SetText(" ▓▓ PICOCLAW LAUNCHER ▓▓").
SetTextColor(tcell.ColorAqua).
SetBackgroundColor(tcell.ColorBlack)
header := tview.NewFlex().
AddItem(headerLeft, 0, 1, false).
AddItem(modelTV, 0, 1, false)
sidebar := tview.NewTextView().
SetDynamicColors(true).
SetWrap(false)
sidebar.SetBackgroundColor(tcell.ColorNavy)
activeColor := "[lime]▶ "
inactiveColor := "[gray] "
sbText := "\n"
if pageID == "home" {
sbText += activeColor + "HOME[-]\n"
} else {
sbText += inactiveColor + "HOME[-]\n"
}
if pageID == "schemes" {
sbText += activeColor + "SCHEMES[-]\n"
} else {
sbText += inactiveColor + "SCHEMES[-]\n"
}
if pageID == "users" {
sbText += activeColor + "USERS[-]\n"
} else {
sbText += inactiveColor + "USERS[-]\n"
}
if pageID == "models" {
sbText += activeColor + "MODELS[-]\n"
} else {
sbText += inactiveColor + "MODELS[-]\n"
}
sidebar.SetText(sbText)
footer := hintBar(hint)
grid := tview.NewGrid().
SetRows(1, 0, 1).
SetColumns(16, 0).
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)
return grid
}

View file

@ -12,32 +12,27 @@ import (
func (a *App) newHomePage() tview.Primitive { func (a *App) newHomePage() tview.Primitive {
list := tview.NewList() list := tview.NewList()
list.SetBorder(true).SetTitle(" picoclaw-launcher-tui ") list.SetBorder(true).SetTitle(" Active Configuration ").SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan)
list.SetMainTextColor(tcell.ColorWhite)
list.SetSecondaryTextColor(tcell.ColorDarkGray)
list.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite))
list.SetSelectedBackgroundColor(tcell.ColorTeal)
list.SetSelectedTextColor(tcell.ColorWhite)
rebuildList := func() { rebuildList := func() {
sel := list.GetCurrentItem() sel := list.GetCurrentItem()
list.Clear() list.Clear()
list.AddItem("model: "+a.cfg.CurrentModelLabel(), "Enter to configure", 'm', func() { list.AddItem("model: "+a.cfg.CurrentModelLabel(), "Enter to configure", 'm', func() {
a.pages.RemovePage("schemes")
a.navigateTo("schemes", a.newSchemesPage()) a.navigateTo("schemes", a.newSchemesPage())
}) })
list.AddItem("Quit", "", 'q', func() { a.tapp.Stop() }) list.AddItem("Quit", "", 'q', func() { a.tapp.Stop() })
if sel > 0 && sel < list.GetItemCount() { if sel >= 0 && sel < list.GetItemCount() {
list.SetCurrentItem(sel) list.SetCurrentItem(sel)
} }
} }
rebuildList() rebuildList()
a.homeRefreshFn = rebuildList a.pageRefreshFns["home"] = rebuildList
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { return a.buildShell("home", list, " m: configure model q: quit ")
return event
})
footer := hintBar(" Enter: select q: quit ")
return tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(list, 0, 1, true).
AddItem(footer, 1, 0, false)
} }

View file

@ -33,7 +33,9 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
SetBorders(false). SetBorders(false).
SetSelectable(true, false). SetSelectable(true, false).
SetFixed(0, 0) SetFixed(0, 0)
table.SetBorder(true).SetTitle(fmt.Sprintf(" Models %s / %s ", schemeName, userName)) table.SetBorder(true).SetTitle(fmt.Sprintf(" Models · %s / %s ", schemeName, userName))
table.SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan)
table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite))
var modelIDs []string var modelIDs []string
@ -41,19 +43,35 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
SetTextAlign(tview.AlignCenter). SetTextAlign(tview.AlignCenter).
SetDynamicColors(true). SetDynamicColors(true).
SetText("[yellow]Fetching models…[-]") SetText("[yellow]Fetching models…[-]")
status.SetBackgroundColor(tcell.ColorBlack)
footer := hintBar(" Enter: select ESC: back ")
flex := tview.NewFlex(). flex := tview.NewFlex().
SetDirection(tview.FlexRow). SetDirection(tview.FlexRow).
AddItem(status, 1, 0, false). AddItem(status, 1, 0, false).
AddItem(table, 0, 1, false). AddItem(table, 0, 1, false)
AddItem(footer, 1, 0, false)
apiKey := a.resolveKey(schemeName, userName) apiKey := a.resolveKey(schemeName, userName)
go func() { go func() {
entries, err := fetchModels(baseURL, apiKey) 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() { a.tapp.QueueUpdateDraw(func() {
if err != nil { if err != nil {
status.SetText(fmt.Sprintf("[red]Error: %s[-]", err.Error())) status.SetText(fmt.Sprintf("[red]Error: %s[-]", err.Error()))
@ -68,7 +86,7 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
return return
} }
status.SetText(fmt.Sprintf("[green]%d model(s) loaded[-]", len(entries))) status.SetText(fmt.Sprintf("[lime]%d model(s) loaded[-]", len(entries)))
for i, m := range entries { for i, m := range entries {
modelIDs = append(modelIDs, m.ID) modelIDs = append(modelIDs, m.ID)
table.SetCell(i, 0, table.SetCell(i, 0,
@ -80,7 +98,8 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
table.SetCell(i, 1, table.SetCell(i, 1,
tview.NewTableCell(" "+m.ID). tview.NewTableCell(" "+m.ID).
SetAlign(tview.AlignLeft). SetAlign(tview.AlignLeft).
SetExpansion(1), SetExpansion(1).
SetTextColor(tcell.ColorWhite),
) )
} }
a.tapp.SetFocus(table) a.tapp.SetFocus(table)
@ -100,7 +119,7 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
a.goBack() a.goBack()
}) })
return flex return a.buildShell("models", flex, " Enter: select ESC: back ")
} }
func (a *App) resolveKey(schemeName, userName string) string { func (a *App) resolveKey(schemeName, userName string) string {
@ -135,9 +154,20 @@ func fetchModels(baseURL, apiKey string) ([]modelEntry, error) {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
} }
var result modelsAPIResponse body, err := io.ReadAll(resp.Body)
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err != nil {
return nil, fmt.Errorf("decode response: %w", err) return nil, fmt.Errorf("read response: %w", err)
} }
return result.Data, nil
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: unrecognised shape: %s", strings.TrimSpace(string(body[:min(len(body), 256)])))
} }

View file

@ -14,74 +14,159 @@ import (
) )
func (a *App) newSchemesPage() tview.Primitive { func (a *App) newSchemesPage() tview.Primitive {
list := tview.NewList() table := tview.NewTable().
list.SetBorder(true).SetTitle(" Provider Schemes (a:add e:edit d:delete Enter:users) ") SetBorders(false).
SetSelectable(true, false)
table.SetBorder(true).SetTitle(" Provider Schemes ")
table.SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan)
table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite))
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() { rebuild := func() {
sel := list.GetCurrentItem() selName := selectedSchemeName()
list.Clear() table.Clear()
for _, s := range a.cfg.Provider.Schemes { schemes := a.cfg.Provider.Schemes
name := s.Name for i, s := range schemes {
list.AddItem( nameRow := i * 2
fmt.Sprintf("%s · %s [%s]", s.Name, s.BaseURL, s.Type), detailRow := nameRow + 1
"",
0, table.SetCell(nameRow, 0,
func() { tview.NewTableCell(" "+s.Name).
a.pages.RemovePage("users") SetTextColor(tcell.ColorWhite).
a.navigateTo("users", a.newUsersPage(name)) 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(" (%d/%d)%s", m, n, s.BaseURL)).
SetTextColor(tcell.ColorDarkGray).
SetExpansion(1).
SetSelectable(false),
)
table.SetCell(detailRow, 1,
tview.NewTableCell(s.Type+" ").
SetTextColor(tcell.ColorDarkGray).
SetAlign(tview.AlignRight).
SetSelectable(false),
) )
} }
if sel >= 0 && sel < list.GetItemCount() { if selName != "" {
list.SetCurrentItem(sel) for i, s := range schemes {
if s.Name == selName {
table.Select(i*2, 0)
return
}
}
}
if table.GetRowCount() > 0 {
table.Select(0, 0)
} }
} }
rebuild() rebuild()
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 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() { switch event.Rune() {
case 'a': case 'a':
a.showSchemeForm(nil, func(s tuicfg.Scheme) { a.showSchemeForm(nil, func(s tuicfg.Scheme) {
a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s) a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s)
a.save() a.save()
rebuild() a.refreshModelCache(rebuild)
}) })
return nil return nil
case 'e': case 'e':
idx := list.GetCurrentItem() if idx < 0 || idx >= len(schemes) {
if idx < 0 || idx >= len(a.cfg.Provider.Schemes) {
return nil return nil
} }
orig := a.cfg.Provider.Schemes[idx] origName := schemes[idx].Name
orig := schemes[idx]
a.showSchemeForm(&orig, func(s tuicfg.Scheme) { a.showSchemeForm(&orig, func(s tuicfg.Scheme) {
a.cfg.Provider.Schemes[idx] = s current := a.cfg.Provider.Schemes
for i, sc := range current {
if sc.Name == origName {
a.cfg.Provider.Schemes[i] = s
break
}
}
a.save() a.save()
rebuild() 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 return nil
case 'd': case 'd':
idx := list.GetCurrentItem() if idx < 0 || idx >= len(schemes) {
if idx < 0 || idx >= len(a.cfg.Provider.Schemes) {
return nil return nil
} }
name := a.cfg.Provider.Schemes[idx].Name name := schemes[idx].Name
a.confirmDelete(fmt.Sprintf("scheme %q", name), func() { a.confirmDelete(fmt.Sprintf("scheme %q", name), func() {
schemes := a.cfg.Provider.Schemes current := a.cfg.Provider.Schemes
a.cfg.Provider.Schemes = append(schemes[:idx], schemes[idx+1:]...) 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.save()
rebuild() a.refreshModelCache(rebuild)
}) })
return nil return nil
} }
return event return event
}) })
footer := hintBar(" Enter: users a: add e: edit d: delete ESC: back ") return a.buildShell("schemes", table, " a: add e: edit d: delete Enter: open 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)) { func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) {
@ -108,14 +193,11 @@ func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)
form := tview.NewForm() form := tview.NewForm()
var nameField *tview.InputField
form. form.
AddInputField("Name", name, 40, nil, func(text string) { name = text }). AddInputField("Name", name, 32, nil, func(text string) { name = text }).
AddInputField("Base URL", baseURL, 60, nil, func(text string) { baseURL = text }). AddInputField("Base URL", baseURL, 32, nil, func(text string) { baseURL = text }).
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }). AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }).
AddButton("Save", func() { AddButton("Save", func() {
_ = nameField
if name == "" { if name == "" {
a.showError("Name is required") a.showError("Name is required")
return return
@ -139,9 +221,20 @@ func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)
a.hideModal("scheme-form") a.hideModal("scheme-form")
}) })
nameField, _ = form.GetFormItemByLabel("Name").(*tview.InputField) form.SetBorder(true).SetTitle(title).SetTitleColor(tcell.ColorLime)
form.SetBorderColor(tcell.ColorDarkCyan)
form.SetFieldBackgroundColor(tcell.ColorBlack)
form.SetFieldTextColor(tcell.ColorWhite)
form.SetLabelColor(tcell.ColorAqua)
form.SetButtonBackgroundColor(tcell.ColorDarkCyan)
form.SetButtonTextColor(tcell.ColorWhite)
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
a.hideModal("scheme-form")
return nil
}
return event
})
form.SetBorder(true).SetTitle(title) a.showModal("scheme-form", centeredForm(form, 6, 12))
a.showModal("scheme-form", centeredForm(form, 68, 12))
} }

View file

@ -14,98 +14,173 @@ import (
) )
func (a *App) newUsersPage(schemeName string) tview.Primitive { func (a *App) newUsersPage(schemeName string) tview.Primitive {
list := tview.NewList() table := tview.NewTable().
list.SetBorder(true).SetTitle(fmt.Sprintf(" Users for scheme %q (a:add e:edit d:delete Enter:models) ", schemeName)) SetBorders(false).
SetSelectable(true, false)
table.SetBorder(true).SetTitle(fmt.Sprintf(" Users · %s ", schemeName))
table.SetTitleColor(tcell.ColorAqua).SetBorderColor(tcell.ColorDarkCyan)
table.SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorTeal).Foreground(tcell.ColorWhite))
indexInCfg := func(visibleIdx int) int { visibleUsers := func() []tuicfg.User {
count := 0 var out []tuicfg.User
for i, u := range a.cfg.Provider.Users { for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName { if u.Scheme == schemeName {
if count == visibleIdx { out = append(out, u)
return i }
} }
count++ 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 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() { rebuild := func() {
sel := list.GetCurrentItem() selName := selectedUserName()
list.Clear() table.Clear()
for _, u := range a.cfg.Provider.Users { users := visibleUsers()
if u.Scheme != schemeName { for i, u := range users {
continue nameRow := i * 2
detailRow := nameRow + 1
table.SetCell(nameRow, 0,
tview.NewTableCell(" "+u.Name).
SetTextColor(tcell.ColorWhite).
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(" %d models", len(models))
} else {
detailText = " [red]Inactive[-]"
} }
uName := u.Name table.SetCell(detailRow, 0,
uType := u.Type tview.NewTableCell(detailText).
list.AddItem( SetTextColor(tcell.ColorDarkGray).
fmt.Sprintf("%s · %s", u.Name, uType), SetExpansion(1).
"", SetSelectable(false),
0, )
func() { table.SetCell(detailRow, 1,
a.pages.RemovePage("models") tview.NewTableCell(u.Type+" ").
scheme := a.cfg.Provider.SchemeByName(schemeName) SetTextColor(tcell.ColorDarkGray).
if scheme == nil { SetAlign(tview.AlignRight).
a.showError(fmt.Sprintf("Scheme %q not found", schemeName)) SetSelectable(false),
return
}
a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL))
},
) )
} }
if sel >= 0 && sel < list.GetItemCount() { if selName != "" {
list.SetCurrentItem(sel) for i, u := range users {
if u.Name == selName {
table.Select(i*2, 0)
return
}
}
}
if table.GetRowCount() > 0 {
table.Select(0, 0)
} }
} }
rebuild() rebuild()
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 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() { switch event.Rune() {
case 'a': case 'a':
a.showUserForm(schemeName, nil, func(u tuicfg.User) { a.showUserForm(schemeName, nil, func(u tuicfg.User) {
a.cfg.Provider.Users = append(a.cfg.Provider.Users, u) a.cfg.Provider.Users = append(a.cfg.Provider.Users, u)
a.save() a.save()
rebuild() a.refreshModelCache(rebuild)
}) })
return nil return nil
case 'e': case 'e':
visIdx := list.GetCurrentItem() if visIdx < 0 || visIdx >= len(users) {
cfgIdx := indexInCfg(visIdx)
if cfgIdx < 0 {
return nil return nil
} }
orig := a.cfg.Provider.Users[cfgIdx] origName := users[visIdx].Name
orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)]
a.showUserForm(schemeName, &orig, func(u tuicfg.User) { 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.cfg.Provider.Users[cfgIdx] = u
a.save() a.save()
rebuild() a.refreshModelCache(func() {
rebuild()
for i, usr := range visibleUsers() {
if usr.Name == u.Name {
table.Select(i*2, 0)
break
}
}
})
}) })
return nil return nil
case 'd': case 'd':
visIdx := list.GetCurrentItem() if visIdx < 0 || visIdx >= len(users) {
cfgIdx := indexInCfg(visIdx)
if cfgIdx < 0 {
return nil return nil
} }
uName := a.cfg.Provider.Users[cfgIdx].Name uName := users[visIdx].Name
a.confirmDelete(fmt.Sprintf("user %q", uName), func() { a.confirmDelete(fmt.Sprintf("user %q", uName), func() {
users := a.cfg.Provider.Users cfgIdx := findUserGlobalIdx(uName)
a.cfg.Provider.Users = append(users[:cfgIdx], users[cfgIdx+1:]...) if cfgIdx < 0 {
return
}
all := a.cfg.Provider.Users
a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...)
a.save() a.save()
rebuild() a.refreshModelCache(rebuild)
}) })
return nil return nil
} }
return event return event
}) })
footer := hintBar(" Enter: select model a: add e: edit d: delete ESC: back ") return a.buildShell("users", table, " a: add e: edit d: delete Enter: models 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)) { func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) {
@ -132,9 +207,9 @@ func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func
form := tview.NewForm() form := tview.NewForm()
form. form.
AddInputField("Name", name, 40, nil, func(text string) { name = text }). AddInputField("Name", name, 32, nil, func(text string) { name = text }).
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }). AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }).
AddPasswordField("Key", key, 60, '*', func(text string) { key = text }). AddPasswordField("Key", key, 32, '*', func(text string) { key = text }).
AddButton("Save", func() { AddButton("Save", func() {
if name == "" { if name == "" {
a.showError("Name is required") a.showError("Name is required")
@ -155,7 +230,20 @@ func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func
a.hideModal("user-form") a.hideModal("user-form")
}) })
form.SetBorder(true).SetTitle(title) form.SetBorder(true).SetTitle(title).SetTitleColor(tcell.ColorLime)
form.SetBorderColor(tcell.ColorDarkCyan)
form.SetFieldBackgroundColor(tcell.ColorBlack)
form.SetFieldTextColor(tcell.ColorWhite)
form.SetLabelColor(tcell.ColorAqua)
form.SetButtonBackgroundColor(tcell.ColorDarkCyan)
form.SetButtonTextColor(tcell.ColorWhite)
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, 68, 13)) a.showModal("user-form", centeredForm(form, 6, 13))
} }