mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Enhance model selection and add footer navigation instructions (#1271)
* fix(tui): fix model selection and enforce unique model_name, also fix model form button highlight * feat(tui): add footer view with navigation instructions and update menu structure * fix(tui): update model selection labels for clarity and consistency * refactor(tui): remove unused rootChannelDescription function * refactor(tui): simplify rootModelDescription and remove unused 'q' event handling in channel menu * fix(tui): keep selected model name updated Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -67,6 +68,7 @@ func Run() error {
|
||||
root := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
root.AddItem(bannerView(), 6, 0, false)
|
||||
root.AddItem(state.pages, 0, 1, true)
|
||||
root.AddItem(footerView(), 1, 0, false)
|
||||
|
||||
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
|
||||
return err
|
||||
@@ -102,7 +104,7 @@ func (s *appState) pop() {
|
||||
}
|
||||
|
||||
func (s *appState) mainMenu() tview.Primitive {
|
||||
menu := NewMenu("Config Menu", nil)
|
||||
menu := NewMenu("Menu", nil)
|
||||
refreshMainMenu(menu, s)
|
||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Key() {
|
||||
@@ -110,10 +112,7 @@ func (s *appState) mainMenu() tview.Primitive {
|
||||
s.requestExit()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'q' {
|
||||
s.requestExit()
|
||||
return nil
|
||||
}
|
||||
|
||||
return event
|
||||
})
|
||||
|
||||
@@ -131,6 +130,32 @@ func (s *appState) refreshMenu(name string, menu *Menu) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *appState) countChannels() (enabled int, total int) {
|
||||
c := s.config.Channels
|
||||
entries := []bool{
|
||||
c.Telegram.Enabled,
|
||||
c.Discord.Enabled,
|
||||
c.QQ.Enabled,
|
||||
c.MaixCam.Enabled,
|
||||
c.WhatsApp.Enabled,
|
||||
c.Feishu.Enabled,
|
||||
c.DingTalk.Enabled,
|
||||
c.Slack.Enabled,
|
||||
c.Matrix.Enabled,
|
||||
c.LINE.Enabled,
|
||||
c.OneBot.Enabled,
|
||||
c.WeCom.Enabled,
|
||||
c.WeComApp.Enabled,
|
||||
}
|
||||
total = len(entries)
|
||||
for _, v := range entries {
|
||||
if v {
|
||||
enabled++
|
||||
}
|
||||
}
|
||||
return enabled, total
|
||||
}
|
||||
|
||||
func refreshMainMenuIfPresent(s *appState) {
|
||||
if menu, ok := s.menus["main"]; ok {
|
||||
refreshMainMenu(menu, s)
|
||||
@@ -141,6 +166,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
|
||||
selectedModel := s.selectedModelName()
|
||||
modelReady := selectedModel != ""
|
||||
channelReady := s.hasEnabledChannel()
|
||||
enabledCount, totalChannels := s.countChannels()
|
||||
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
|
||||
|
||||
gatewayLabel := "Start Gateway"
|
||||
@@ -153,7 +179,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
|
||||
items := []MenuItem{
|
||||
{
|
||||
Label: rootModelLabel(selectedModel),
|
||||
Description: rootModelDescription(selectedModel),
|
||||
Description: rootModelDescription(),
|
||||
Action: func() {
|
||||
s.push("model", s.modelMenu())
|
||||
},
|
||||
@@ -167,7 +193,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
|
||||
},
|
||||
{
|
||||
Label: rootChannelLabel(channelReady),
|
||||
Description: rootChannelDescription(channelReady),
|
||||
Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels),
|
||||
Action: func() {
|
||||
s.push("channel", s.channelMenu())
|
||||
},
|
||||
@@ -311,16 +337,13 @@ func (s *appState) selectedModelName() string {
|
||||
|
||||
func rootModelLabel(selected string) string {
|
||||
if selected == "" {
|
||||
return "Model (no model selected)"
|
||||
return "Model (None)"
|
||||
}
|
||||
return "Model (" + selected + ")"
|
||||
}
|
||||
|
||||
func rootModelDescription(selected string) string {
|
||||
if selected == "" {
|
||||
return "no model selected"
|
||||
}
|
||||
return "selected"
|
||||
func rootModelDescription() string {
|
||||
return "Using SPACE to choose your model"
|
||||
}
|
||||
|
||||
func rootChannelLabel(valid bool) string {
|
||||
@@ -330,13 +353,6 @@ func rootChannelLabel(valid bool) string {
|
||||
return "Channel"
|
||||
}
|
||||
|
||||
func rootChannelDescription(valid bool) string {
|
||||
if !valid {
|
||||
return "no channel enabled"
|
||||
}
|
||||
return "enabled"
|
||||
}
|
||||
|
||||
func (s *appState) startTalk() {
|
||||
if !s.isActiveModelValid() {
|
||||
s.showMessage("Model required", "Select a valid model before starting talk")
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
func (s *appState) buildChannelMenuItems() []MenuItem {
|
||||
return []MenuItem{
|
||||
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||
channelItem(
|
||||
"Telegram",
|
||||
"Telegram bot settings",
|
||||
@@ -101,10 +100,6 @@ func (s *appState) channelMenu() tview.Primitive {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'q' {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
return menu
|
||||
|
||||
@@ -14,23 +14,7 @@ import (
|
||||
)
|
||||
|
||||
func (s *appState) modelMenu() tview.Primitive {
|
||||
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
||||
items = append(items,
|
||||
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||
MenuItem{
|
||||
Label: "Add model",
|
||||
Description: "Append a new model entry",
|
||||
Action: func() {
|
||||
s.addModel(
|
||||
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
||||
)
|
||||
s.push(
|
||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||
s.modelForm(len(s.config.ModelList)-1),
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
|
||||
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||
for i := range s.config.ModelList {
|
||||
index := i
|
||||
@@ -57,6 +41,23 @@ func (s *appState) modelMenu() tview.Primitive {
|
||||
},
|
||||
})
|
||||
}
|
||||
// Add model entry appended at the end so the models map to rows 1..N
|
||||
items = append(items,
|
||||
MenuItem{
|
||||
Label: "**Add model**",
|
||||
Description: "Append a new model entry",
|
||||
Action: func() {
|
||||
newName := s.nextAvailableModelName("new-model")
|
||||
s.addModel(
|
||||
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.2"},
|
||||
)
|
||||
s.push(
|
||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||
s.modelForm(len(s.config.ModelList)-1),
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
menu := NewMenu("Models", items)
|
||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
@@ -64,14 +65,11 @@ func (s *appState) modelMenu() tview.Primitive {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'q' {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
|
||||
if event.Rune() == ' ' {
|
||||
row, _ := menu.GetSelection()
|
||||
if row > 0 && row <= len(s.config.ModelList) {
|
||||
model := s.config.ModelList[row-1]
|
||||
if row >= 0 && row < len(s.config.ModelList) {
|
||||
model := s.config.ModelList[row]
|
||||
if !isModelValid(model) {
|
||||
s.showMessage(
|
||||
"Invalid model",
|
||||
@@ -95,12 +93,23 @@ func (s *appState) modelForm(index int) tview.Primitive {
|
||||
model := &s.config.ModelList[index]
|
||||
form := tview.NewForm()
|
||||
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
||||
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
||||
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
||||
|
||||
addInput(form, "Model Name", model.ModelName, func(value string) {
|
||||
if value == "" {
|
||||
s.showMessage("Invalid model name", "Model Name cannot be empty")
|
||||
return
|
||||
}
|
||||
if s.modelNameExists(value, index) {
|
||||
s.showMessage("Duplicate model name", fmt.Sprintf("Model Name '%s' already exists", value))
|
||||
return
|
||||
}
|
||||
oldName := model.ModelName
|
||||
model.ModelName = value
|
||||
if s.config.Agents.Defaults.Model == oldName {
|
||||
s.config.Agents.Defaults.Model = value
|
||||
}
|
||||
s.dirty = true
|
||||
form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["model"]; ok {
|
||||
refreshModelMenuFromState(menu, s)
|
||||
@@ -158,7 +167,21 @@ func (s *appState) modelForm(index int) tview.Primitive {
|
||||
})
|
||||
|
||||
form.AddButton("Delete", func() {
|
||||
s.deleteModel(index)
|
||||
pageName := "confirm-delete-model"
|
||||
if s.pages.HasPage(pageName) {
|
||||
return
|
||||
}
|
||||
modal := tview.NewModal().
|
||||
SetText("Are you sure you want to delete this model?").
|
||||
AddButtons([]string{"Cancel", "Delete"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
s.pages.RemovePage(pageName)
|
||||
if buttonLabel == "Delete" {
|
||||
s.deleteModel(index)
|
||||
}
|
||||
})
|
||||
modal.SetTitle("Confirm Delete").SetBorder(true)
|
||||
s.pages.AddPage(pageName, modal, true, true)
|
||||
})
|
||||
form.AddButton("Test", func() {
|
||||
s.testModel(model)
|
||||
@@ -215,7 +238,7 @@ func modelStatusColor(valid bool, selected bool) *tcell.Color {
|
||||
|
||||
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
|
||||
for i, model := range models {
|
||||
row := i + 1
|
||||
row := i
|
||||
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||
isValid := isModelValid(model)
|
||||
if model.ModelName == currentModel && currentModel != "" {
|
||||
@@ -234,23 +257,7 @@ func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.M
|
||||
}
|
||||
|
||||
func refreshModelMenuFromState(menu *Menu, s *appState) {
|
||||
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
||||
items = append(items,
|
||||
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||
MenuItem{
|
||||
Label: "Add model",
|
||||
Description: "Append a new model entry",
|
||||
Action: func() {
|
||||
s.addModel(
|
||||
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
||||
)
|
||||
s.push(
|
||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||
s.modelForm(len(s.config.ModelList)-1),
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
|
||||
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||
for i := range s.config.ModelList {
|
||||
index := i
|
||||
@@ -277,6 +284,19 @@ func refreshModelMenuFromState(menu *Menu, s *appState) {
|
||||
},
|
||||
})
|
||||
}
|
||||
items = append(items,
|
||||
MenuItem{
|
||||
Label: "**Add Model**",
|
||||
Description: "Append a new model entry",
|
||||
Action: func() {
|
||||
newName := s.nextAvailableModelName("new-model")
|
||||
s.addModel(
|
||||
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.2"},
|
||||
)
|
||||
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
|
||||
},
|
||||
},
|
||||
)
|
||||
menu.applyItems(items)
|
||||
}
|
||||
|
||||
@@ -287,6 +307,38 @@ func isModelValid(model picoclawconfig.ModelConfig) bool {
|
||||
return hasKey && hasModel
|
||||
}
|
||||
|
||||
func (s *appState) modelNameExists(name string, excludeIndex int) bool {
|
||||
target := strings.TrimSpace(name)
|
||||
if target == "" {
|
||||
return false
|
||||
}
|
||||
for i := range s.config.ModelList {
|
||||
if i == excludeIndex {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(s.config.ModelList[i].ModelName) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *appState) nextAvailableModelName(base string) string {
|
||||
name := strings.TrimSpace(base)
|
||||
if name == "" {
|
||||
name = "new-model"
|
||||
}
|
||||
if !s.modelNameExists(name, -1) {
|
||||
return name
|
||||
}
|
||||
for i := 2; ; i++ {
|
||||
candidate := fmt.Sprintf("%s-%d", name, i)
|
||||
if !s.modelNameExists(candidate, -1) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
||||
if model == nil {
|
||||
return
|
||||
|
||||
@@ -41,3 +41,15 @@ func bannerView() *tview.TextView {
|
||||
text.SetBorder(false)
|
||||
return text
|
||||
}
|
||||
|
||||
const footerText = "Esc: Back/Exit | Enter: Enter | ←↓↑→ : Move | Space: Select | Tab/Shift+Tab: Switch"
|
||||
|
||||
func footerView() *tview.TextView {
|
||||
text := tview.NewTextView()
|
||||
text.SetTextAlign(tview.AlignCenter)
|
||||
text.SetText(footerText)
|
||||
text.SetBackgroundColor(tview.Styles.MoreContrastBackgroundColor)
|
||||
text.SetTextColor(tview.Styles.PrimaryTextColor)
|
||||
text.SetBorder(false)
|
||||
return text
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user