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
6 changed files with 545 additions and 147 deletions
@@ -95,6 +95,9 @@ func Load(path string) (*TUIConfig, error) {
// 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 {
+210 -21
View File
@@ -6,6 +6,9 @@
package ui
import (
"fmt"
"sync"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
@@ -13,26 +16,119 @@ import (
// App is the root TUI application.
type App struct {
tapp *tview.Application
pages *tview.Pages
pageStack []string
cfg *tuicfg.TUIConfig
configPath string
homeRefreshFn func()
tapp *tview.Application
pages *tview.Pages
pageStack []string
cfg *tuicfg.TUIConfig
configPath string
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.
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{
tapp: tview.NewApplication(),
pages: tview.NewPages(),
pageStack: []string{},
cfg: cfg,
configPath: configPath,
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 nil
}
return a.goBack()
}
return event
@@ -53,6 +149,7 @@ func (a *App) buildPages() {
}
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)
@@ -62,26 +159,35 @@ 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 prev == "home" && a.homeRefreshFn != nil {
a.homeRefreshFn()
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() {
_ = 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) {
@@ -91,6 +197,10 @@ func (a *App) showError(msg string) {
SetDoneFunc(func(_ int, _ string) {
a.hideModal("error")
})
modal.SetBackgroundColor(tcell.ColorNavy)
modal.SetTextColor(tcell.ColorWhite)
modal.SetButtonBackgroundColor(tcell.ColorDarkCyan)
modal.SetButtonTextColor(tcell.ColorWhite)
a.showModal("error", modal)
}
@@ -104,20 +214,99 @@ func (a *App) confirmDelete(label string, onConfirm func()) {
onConfirm()
}
})
modal.SetBackgroundColor(tcell.ColorNavy)
modal.SetTextColor(tcell.ColorWhite)
modal.SetButtonBackgroundColor(tcell.ColorDarkCyan)
modal.SetButtonTextColor(tcell.ColorWhite)
a.showModal("confirm-delete", modal)
}
func centeredForm(form *tview.Form, width, height int) tview.Primitive {
return tview.NewGrid().
SetColumns(0, width, 0).
SetRows(0, height, 0).
AddItem(form, 1, 1, 1, 1, 0, 0, true)
func 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).
SetTextAlign(tview.AlignCenter)
tv.SetBackgroundColor(tcell.ColorDarkBlue)
SetTextAlign(tview.AlignCenter).
SetTextColor(tcell.ColorAqua)
tv.SetBackgroundColor(tcell.ColorMidnightBlue)
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
}
+9 -14
View File
@@ -12,32 +12,27 @@ import (
func (a *App) newHomePage() tview.Primitive {
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() {
sel := list.GetCurrentItem()
list.Clear()
list.AddItem("model: "+a.cfg.CurrentModelLabel(), "Enter to configure", 'm', func() {
a.pages.RemovePage("schemes")
a.navigateTo("schemes", a.newSchemesPage())
})
list.AddItem("Quit", "", 'q', func() { a.tapp.Stop() })
if sel > 0 && sel < list.GetItemCount() {
if sel >= 0 && sel < list.GetItemCount() {
list.SetCurrentItem(sel)
}
}
rebuildList()
a.homeRefreshFn = rebuildList
a.pageRefreshFns["home"] = rebuildList
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
return event
})
footer := hintBar(" Enter: select q: quit ")
return tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(list, 0, 1, true).
AddItem(footer, 1, 0, false)
return a.buildShell("home", list, " m: configure model q: quit ")
}
+43 -13
View File
@@ -33,7 +33,9 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
SetBorders(false).
SetSelectable(true, false).
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
@@ -41,19 +43,35 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
SetTextAlign(tview.AlignCenter).
SetDynamicColors(true).
SetText("[yellow]Fetching models…[-]")
footer := hintBar(" Enter: select ESC: back ")
status.SetBackgroundColor(tcell.ColorBlack)
flex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(status, 1, 0, false).
AddItem(table, 0, 1, false).
AddItem(footer, 1, 0, false)
AddItem(table, 0, 1, false)
apiKey := a.resolveKey(schemeName, userName)
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() {
if err != nil {
status.SetText(fmt.Sprintf("[red]Error: %s[-]", err.Error()))
@@ -68,7 +86,7 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
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 {
modelIDs = append(modelIDs, m.ID)
table.SetCell(i, 0,
@@ -80,7 +98,8 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
table.SetCell(i, 1,
tview.NewTableCell(" "+m.ID).
SetAlign(tview.AlignLeft).
SetExpansion(1),
SetExpansion(1).
SetTextColor(tcell.ColorWhite),
)
}
a.tapp.SetFocus(table)
@@ -100,7 +119,7 @@ func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitiv
a.goBack()
})
return flex
return a.buildShell("models", flex, " Enter: select ESC: back ")
}
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)))
}
var result modelsAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
body, err := io.ReadAll(resp.Body)
if err != nil {
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)])))
}
+137 -44
View File
@@ -14,74 +14,159 @@ import (
)
func (a *App) newSchemesPage() tview.Primitive {
list := tview.NewList()
list.SetBorder(true).SetTitle(" Provider Schemes (a:add e:edit d:delete Enter:users) ")
table := tview.NewTable().
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() {
sel := list.GetCurrentItem()
list.Clear()
for _, s := range a.cfg.Provider.Schemes {
name := s.Name
list.AddItem(
fmt.Sprintf("%s · %s [%s]", s.Name, s.BaseURL, s.Type),
"",
0,
func() {
a.pages.RemovePage("users")
a.navigateTo("users", a.newUsersPage(name))
},
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.ColorWhite).
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() {
list.SetCurrentItem(sel)
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()
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() {
case 'a':
a.showSchemeForm(nil, func(s tuicfg.Scheme) {
a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s)
a.save()
rebuild()
a.refreshModelCache(rebuild)
})
return nil
case 'e':
idx := list.GetCurrentItem()
if idx < 0 || idx >= len(a.cfg.Provider.Schemes) {
if idx < 0 || idx >= len(schemes) {
return nil
}
orig := a.cfg.Provider.Schemes[idx]
origName := schemes[idx].Name
orig := schemes[idx]
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()
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
case 'd':
idx := list.GetCurrentItem()
if idx < 0 || idx >= len(a.cfg.Provider.Schemes) {
if idx < 0 || idx >= len(schemes) {
return nil
}
name := a.cfg.Provider.Schemes[idx].Name
name := schemes[idx].Name
a.confirmDelete(fmt.Sprintf("scheme %q", name), func() {
schemes := a.cfg.Provider.Schemes
a.cfg.Provider.Schemes = append(schemes[:idx], schemes[idx+1:]...)
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()
rebuild()
a.refreshModelCache(rebuild)
})
return nil
}
return event
})
footer := hintBar(" Enter: users a: add e: edit d: delete ESC: back ")
return tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(list, 0, 1, true).
AddItem(footer, 1, 0, false)
return a.buildShell("schemes", table, " a: add e: edit d: delete Enter: open ESC: back ")
}
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()
var nameField *tview.InputField
form.
AddInputField("Name", name, 40, nil, func(text string) { name = text }).
AddInputField("Base URL", baseURL, 60, nil, func(text string) { baseURL = text }).
AddInputField("Name", name, 32, nil, func(text string) { name = text }).
AddInputField("Base URL", baseURL, 32, nil, func(text string) { baseURL = text }).
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }).
AddButton("Save", func() {
_ = nameField
if name == "" {
a.showError("Name is required")
return
@@ -139,9 +221,20 @@ func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)
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, 68, 12))
a.showModal("scheme-form", centeredForm(form, 6, 12))
}
+143 -55
View File
@@ -14,98 +14,173 @@ import (
)
func (a *App) newUsersPage(schemeName string) tview.Primitive {
list := tview.NewList()
list.SetBorder(true).SetTitle(fmt.Sprintf(" Users for scheme %q (a:add e:edit d:delete Enter:models) ", schemeName))
table := tview.NewTable().
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 {
count := 0
for i, u := range a.cfg.Provider.Users {
visibleUsers := func() []tuicfg.User {
var out []tuicfg.User
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName {
if count == visibleIdx {
return i
}
count++
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() {
sel := list.GetCurrentItem()
list.Clear()
for _, u := range a.cfg.Provider.Users {
if u.Scheme != schemeName {
continue
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.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
uType := u.Type
list.AddItem(
fmt.Sprintf("%s · %s", u.Name, uType),
"",
0,
func() {
a.pages.RemovePage("models")
scheme := a.cfg.Provider.SchemeByName(schemeName)
if scheme == nil {
a.showError(fmt.Sprintf("Scheme %q not found", schemeName))
return
}
a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL))
},
table.SetCell(detailRow, 0,
tview.NewTableCell(detailText).
SetTextColor(tcell.ColorDarkGray).
SetExpansion(1).
SetSelectable(false),
)
table.SetCell(detailRow, 1,
tview.NewTableCell(u.Type+" ").
SetTextColor(tcell.ColorDarkGray).
SetAlign(tview.AlignRight).
SetSelectable(false),
)
}
if sel >= 0 && sel < list.GetItemCount() {
list.SetCurrentItem(sel)
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()
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() {
case 'a':
a.showUserForm(schemeName, nil, func(u tuicfg.User) {
a.cfg.Provider.Users = append(a.cfg.Provider.Users, u)
a.save()
rebuild()
a.refreshModelCache(rebuild)
})
return nil
case 'e':
visIdx := list.GetCurrentItem()
cfgIdx := indexInCfg(visIdx)
if cfgIdx < 0 {
if visIdx < 0 || visIdx >= len(users) {
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) {
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()
rebuild()
a.refreshModelCache(func() {
rebuild()
for i, usr := range visibleUsers() {
if usr.Name == u.Name {
table.Select(i*2, 0)
break
}
}
})
})
return nil
case 'd':
visIdx := list.GetCurrentItem()
cfgIdx := indexInCfg(visIdx)
if cfgIdx < 0 {
if visIdx < 0 || visIdx >= len(users) {
return nil
}
uName := a.cfg.Provider.Users[cfgIdx].Name
uName := users[visIdx].Name
a.confirmDelete(fmt.Sprintf("user %q", uName), func() {
users := a.cfg.Provider.Users
a.cfg.Provider.Users = append(users[:cfgIdx], users[cfgIdx+1:]...)
cfgIdx := findUserGlobalIdx(uName)
if cfgIdx < 0 {
return
}
all := a.cfg.Provider.Users
a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...)
a.save()
rebuild()
a.refreshModelCache(rebuild)
})
return nil
}
return event
})
footer := hintBar(" Enter: select model a: add e: edit d: delete ESC: back ")
return tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(list, 0, 1, true).
AddItem(footer, 1, 0, false)
return a.buildShell("users", table, " a: add e: edit d: delete Enter: models ESC: back ")
}
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.
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 }).
AddPasswordField("Key", key, 60, '*', func(text string) { key = text }).
AddPasswordField("Key", key, 32, '*', func(text string) { key = text }).
AddButton("Save", func() {
if name == "" {
a.showError("Name is required")
@@ -155,7 +230,20 @@ func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func
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))
}