From 119cc2e8e156454fa0cc0658ad9b8e3d112e6be1 Mon Sep 17 00:00:00 2001 From: taorye Date: Fri, 20 Mar 2026 15:39:15 +0800 Subject: [PATCH] refactor: enhance TUI configuration and user management with improved UI elements and concurrency --- cmd/picoclaw-launcher-tui/config/config.go | 3 + cmd/picoclaw-launcher-tui/ui/app.go | 231 +++++++++++++++++++-- cmd/picoclaw-launcher-tui/ui/home.go | 23 +- cmd/picoclaw-launcher-tui/ui/models.go | 56 +++-- cmd/picoclaw-launcher-tui/ui/schemes.go | 181 ++++++++++++---- cmd/picoclaw-launcher-tui/ui/users.go | 198 +++++++++++++----- 6 files changed, 545 insertions(+), 147 deletions(-) diff --git a/cmd/picoclaw-launcher-tui/config/config.go b/cmd/picoclaw-launcher-tui/config/config.go index 15c81f90a..28bee27cd 100644 --- a/cmd/picoclaw-launcher-tui/config/config.go +++ b/cmd/picoclaw-launcher-tui/config/config.go @@ -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 { diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go index c642a1753..b0f1799ea 100644 --- a/cmd/picoclaw-launcher-tui/ui/app.go +++ b/cmd/picoclaw-launcher-tui/ui/app.go @@ -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 +} diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go index 6235a2c8e..af25f9b43 100644 --- a/cmd/picoclaw-launcher-tui/ui/home.go +++ b/cmd/picoclaw-launcher-tui/ui/home.go @@ -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 ") } diff --git a/cmd/picoclaw-launcher-tui/ui/models.go b/cmd/picoclaw-launcher-tui/ui/models.go index 5e102d94c..c9747d544 100644 --- a/cmd/picoclaw-launcher-tui/ui/models.go +++ b/cmd/picoclaw-launcher-tui/ui/models.go @@ -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)]))) } diff --git a/cmd/picoclaw-launcher-tui/ui/schemes.go b/cmd/picoclaw-launcher-tui/ui/schemes.go index eec3bda7c..92cae3b42 100644 --- a/cmd/picoclaw-launcher-tui/ui/schemes.go +++ b/cmd/picoclaw-launcher-tui/ui/schemes.go @@ -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)) } diff --git a/cmd/picoclaw-launcher-tui/ui/users.go b/cmd/picoclaw-launcher-tui/ui/users.go index 27b7cea7a..f561938d5 100644 --- a/cmd/picoclaw-launcher-tui/ui/users.go +++ b/cmd/picoclaw-launcher-tui/ui/users.go @@ -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)) }