Skip to content

Commit 314cc27

Browse files
taonyxCopilot
andauthored
Enhance model selection and add footer navigation instructions (sipeed#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 <[email protected]> --------- Co-authored-by: Copilot <[email protected]>
1 parent aa2ea0a commit 314cc27

4 files changed

Lines changed: 144 additions & 69 deletions

File tree

cmd/picoclaw-launcher-tui/internal/ui/app.go

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package ui
22

33
import (
4+
"fmt"
45
"os"
56
"os/exec"
67
"path/filepath"
@@ -67,6 +68,7 @@ func Run() error {
6768
root := tview.NewFlex().SetDirection(tview.FlexRow)
6869
root.AddItem(bannerView(), 6, 0, false)
6970
root.AddItem(state.pages, 0, 1, true)
71+
root.AddItem(footerView(), 1, 0, false)
7072

7173
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
7274
return err
@@ -102,18 +104,15 @@ func (s *appState) pop() {
102104
}
103105

104106
func (s *appState) mainMenu() tview.Primitive {
105-
menu := NewMenu("Config Menu", nil)
107+
menu := NewMenu("Menu", nil)
106108
refreshMainMenu(menu, s)
107109
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
108110
switch event.Key() {
109111
case tcell.KeyEsc:
110112
s.requestExit()
111113
return nil
112114
}
113-
if event.Rune() == 'q' {
114-
s.requestExit()
115-
return nil
116-
}
115+
117116
return event
118117
})
119118

@@ -131,6 +130,32 @@ func (s *appState) refreshMenu(name string, menu *Menu) {
131130
}
132131
}
133132

133+
func (s *appState) countChannels() (enabled int, total int) {
134+
c := s.config.Channels
135+
entries := []bool{
136+
c.Telegram.Enabled,
137+
c.Discord.Enabled,
138+
c.QQ.Enabled,
139+
c.MaixCam.Enabled,
140+
c.WhatsApp.Enabled,
141+
c.Feishu.Enabled,
142+
c.DingTalk.Enabled,
143+
c.Slack.Enabled,
144+
c.Matrix.Enabled,
145+
c.LINE.Enabled,
146+
c.OneBot.Enabled,
147+
c.WeCom.Enabled,
148+
c.WeComApp.Enabled,
149+
}
150+
total = len(entries)
151+
for _, v := range entries {
152+
if v {
153+
enabled++
154+
}
155+
}
156+
return enabled, total
157+
}
158+
134159
func refreshMainMenuIfPresent(s *appState) {
135160
if menu, ok := s.menus["main"]; ok {
136161
refreshMainMenu(menu, s)
@@ -141,6 +166,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
141166
selectedModel := s.selectedModelName()
142167
modelReady := selectedModel != ""
143168
channelReady := s.hasEnabledChannel()
169+
enabledCount, totalChannels := s.countChannels()
144170
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
145171

146172
gatewayLabel := "Start Gateway"
@@ -153,7 +179,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
153179
items := []MenuItem{
154180
{
155181
Label: rootModelLabel(selectedModel),
156-
Description: rootModelDescription(selectedModel),
182+
Description: rootModelDescription(),
157183
Action: func() {
158184
s.push("model", s.modelMenu())
159185
},
@@ -167,7 +193,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
167193
},
168194
{
169195
Label: rootChannelLabel(channelReady),
170-
Description: rootChannelDescription(channelReady),
196+
Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels),
171197
Action: func() {
172198
s.push("channel", s.channelMenu())
173199
},
@@ -311,16 +337,13 @@ func (s *appState) selectedModelName() string {
311337

312338
func rootModelLabel(selected string) string {
313339
if selected == "" {
314-
return "Model (no model selected)"
340+
return "Model (None)"
315341
}
316342
return "Model (" + selected + ")"
317343
}
318344

319-
func rootModelDescription(selected string) string {
320-
if selected == "" {
321-
return "no model selected"
322-
}
323-
return "selected"
345+
func rootModelDescription() string {
346+
return "Using SPACE to choose your model"
324347
}
325348

326349
func rootChannelLabel(valid bool) string {
@@ -330,13 +353,6 @@ func rootChannelLabel(valid bool) string {
330353
return "Channel"
331354
}
332355

333-
func rootChannelDescription(valid bool) string {
334-
if !valid {
335-
return "no channel enabled"
336-
}
337-
return "enabled"
338-
}
339-
340356
func (s *appState) startTalk() {
341357
if !s.isActiveModelValid() {
342358
s.showMessage("Model required", "Select a valid model before starting talk")

cmd/picoclaw-launcher-tui/internal/ui/channel.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212

1313
func (s *appState) buildChannelMenuItems() []MenuItem {
1414
return []MenuItem{
15-
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
1615
channelItem(
1716
"Telegram",
1817
"Telegram bot settings",
@@ -101,10 +100,6 @@ func (s *appState) channelMenu() tview.Primitive {
101100
s.pop()
102101
return nil
103102
}
104-
if event.Rune() == 'q' {
105-
s.pop()
106-
return nil
107-
}
108103
return event
109104
})
110105
return menu

cmd/picoclaw-launcher-tui/internal/ui/model.go

Lines changed: 96 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,7 @@ import (
1414
)
1515

1616
func (s *appState) modelMenu() tview.Primitive {
17-
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
18-
items = append(items,
19-
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
20-
MenuItem{
21-
Label: "Add model",
22-
Description: "Append a new model entry",
23-
Action: func() {
24-
s.addModel(
25-
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
26-
)
27-
s.push(
28-
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
29-
s.modelForm(len(s.config.ModelList)-1),
30-
)
31-
},
32-
},
33-
)
17+
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
3418
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
3519
for i := range s.config.ModelList {
3620
index := i
@@ -57,21 +41,35 @@ func (s *appState) modelMenu() tview.Primitive {
5741
},
5842
})
5943
}
44+
// Add model entry appended at the end so the models map to rows 1..N
45+
items = append(items,
46+
MenuItem{
47+
Label: "**Add model**",
48+
Description: "Append a new model entry",
49+
Action: func() {
50+
newName := s.nextAvailableModelName("new-model")
51+
s.addModel(
52+
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.2"},
53+
)
54+
s.push(
55+
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
56+
s.modelForm(len(s.config.ModelList)-1),
57+
)
58+
},
59+
},
60+
)
6061

6162
menu := NewMenu("Models", items)
6263
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
6364
if event.Key() == tcell.KeyEsc {
6465
s.pop()
6566
return nil
6667
}
67-
if event.Rune() == 'q' {
68-
s.pop()
69-
return nil
70-
}
68+
7169
if event.Rune() == ' ' {
7270
row, _ := menu.GetSelection()
73-
if row > 0 && row <= len(s.config.ModelList) {
74-
model := s.config.ModelList[row-1]
71+
if row >= 0 && row < len(s.config.ModelList) {
72+
model := s.config.ModelList[row]
7573
if !isModelValid(model) {
7674
s.showMessage(
7775
"Invalid model",
@@ -95,12 +93,23 @@ func (s *appState) modelForm(index int) tview.Primitive {
9593
model := &s.config.ModelList[index]
9694
form := tview.NewForm()
9795
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
98-
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
99-
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
10096

10197
addInput(form, "Model Name", model.ModelName, func(value string) {
98+
if value == "" {
99+
s.showMessage("Invalid model name", "Model Name cannot be empty")
100+
return
101+
}
102+
if s.modelNameExists(value, index) {
103+
s.showMessage("Duplicate model name", fmt.Sprintf("Model Name '%s' already exists", value))
104+
return
105+
}
106+
oldName := model.ModelName
102107
model.ModelName = value
108+
if s.config.Agents.Defaults.Model == oldName {
109+
s.config.Agents.Defaults.Model = value
110+
}
103111
s.dirty = true
112+
form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
104113
refreshMainMenuIfPresent(s)
105114
if menu, ok := s.menus["model"]; ok {
106115
refreshModelMenuFromState(menu, s)
@@ -158,7 +167,21 @@ func (s *appState) modelForm(index int) tview.Primitive {
158167
})
159168

160169
form.AddButton("Delete", func() {
161-
s.deleteModel(index)
170+
pageName := "confirm-delete-model"
171+
if s.pages.HasPage(pageName) {
172+
return
173+
}
174+
modal := tview.NewModal().
175+
SetText("Are you sure you want to delete this model?").
176+
AddButtons([]string{"Cancel", "Delete"}).
177+
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
178+
s.pages.RemovePage(pageName)
179+
if buttonLabel == "Delete" {
180+
s.deleteModel(index)
181+
}
182+
})
183+
modal.SetTitle("Confirm Delete").SetBorder(true)
184+
s.pages.AddPage(pageName, modal, true, true)
162185
})
163186
form.AddButton("Test", func() {
164187
s.testModel(model)
@@ -215,7 +238,7 @@ func modelStatusColor(valid bool, selected bool) *tcell.Color {
215238

216239
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
217240
for i, model := range models {
218-
row := i + 1
241+
row := i
219242
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
220243
isValid := isModelValid(model)
221244
if model.ModelName == currentModel && currentModel != "" {
@@ -234,23 +257,7 @@ func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.M
234257
}
235258

236259
func refreshModelMenuFromState(menu *Menu, s *appState) {
237-
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
238-
items = append(items,
239-
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
240-
MenuItem{
241-
Label: "Add model",
242-
Description: "Append a new model entry",
243-
Action: func() {
244-
s.addModel(
245-
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
246-
)
247-
s.push(
248-
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
249-
s.modelForm(len(s.config.ModelList)-1),
250-
)
251-
},
252-
},
253-
)
260+
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
254261
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
255262
for i := range s.config.ModelList {
256263
index := i
@@ -277,6 +284,19 @@ func refreshModelMenuFromState(menu *Menu, s *appState) {
277284
},
278285
})
279286
}
287+
items = append(items,
288+
MenuItem{
289+
Label: "**Add Model**",
290+
Description: "Append a new model entry",
291+
Action: func() {
292+
newName := s.nextAvailableModelName("new-model")
293+
s.addModel(
294+
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.2"},
295+
)
296+
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
297+
},
298+
},
299+
)
280300
menu.applyItems(items)
281301
}
282302

@@ -287,6 +307,38 @@ func isModelValid(model picoclawconfig.ModelConfig) bool {
287307
return hasKey && hasModel
288308
}
289309

310+
func (s *appState) modelNameExists(name string, excludeIndex int) bool {
311+
target := strings.TrimSpace(name)
312+
if target == "" {
313+
return false
314+
}
315+
for i := range s.config.ModelList {
316+
if i == excludeIndex {
317+
continue
318+
}
319+
if strings.TrimSpace(s.config.ModelList[i].ModelName) == target {
320+
return true
321+
}
322+
}
323+
return false
324+
}
325+
326+
func (s *appState) nextAvailableModelName(base string) string {
327+
name := strings.TrimSpace(base)
328+
if name == "" {
329+
name = "new-model"
330+
}
331+
if !s.modelNameExists(name, -1) {
332+
return name
333+
}
334+
for i := 2; ; i++ {
335+
candidate := fmt.Sprintf("%s-%d", name, i)
336+
if !s.modelNameExists(candidate, -1) {
337+
return candidate
338+
}
339+
}
340+
}
341+
290342
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
291343
if model == nil {
292344
return

cmd/picoclaw-launcher-tui/internal/ui/style.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,15 @@ func bannerView() *tview.TextView {
4141
text.SetBorder(false)
4242
return text
4343
}
44+
45+
const footerText = "Esc: Back/Exit | Enter: Enter | ←↓↑→ : Move | Space: Select | Tab/Shift+Tab: Switch"
46+
47+
func footerView() *tview.TextView {
48+
text := tview.NewTextView()
49+
text.SetTextAlign(tview.AlignCenter)
50+
text.SetText(footerText)
51+
text.SetBackgroundColor(tview.Styles.MoreContrastBackgroundColor)
52+
text.SetTextColor(tview.Styles.PrimaryTextColor)
53+
text.SetBorder(false)
54+
return text
55+
}

0 commit comments

Comments
 (0)