diff options
| author | Grail Finder <wohilas@gmail.com> | 2026-02-09 08:52:11 +0300 |
|---|---|---|
| committer | Grail Finder <wohilas@gmail.com> | 2026-02-09 08:52:11 +0300 |
| commit | 77ad2a7e7e2c3bade4d949d8eb5c36e0126f4668 (patch) | |
| tree | 881fcc37cf74f2231e20a24187f98087544ae7f6 | |
| parent | 1bf9e6eef72ec2eec7282b1554b41a0dc3d8d1b8 (diff) | |
Enha: popups from the main window
no longer user has to go to the props table to get a pleasant popup to
choose an option
| -rw-r--r-- | popups.go | 315 | ||||
| -rw-r--r-- | props_table.go | 48 | ||||
| -rw-r--r-- | tui.go | 108 |
3 files changed, 327 insertions, 144 deletions
diff --git a/popups.go b/popups.go new file mode 100644 index 0000000..559a2aa --- /dev/null +++ b/popups.go @@ -0,0 +1,315 @@ +package main + +import ( + "slices" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// showModelSelectionPopup creates a modal popup to select a model +func showModelSelectionPopup() { + // Helper function to get model list for a given API + getModelListForAPI := func(api string) []string { + if strings.Contains(api, "api.deepseek.com/") { + return []string{"deepseek-chat", "deepseek-reasoner"} + } else if strings.Contains(api, "openrouter.ai") { + return ORFreeModels + } + // Assume local llama.cpp + refreshLocalModelsIfEmpty() + localModelsMu.RLock() + defer localModelsMu.RUnlock() + return LocalModels + } + // Get the current model list based on the API + modelList := getModelListForAPI(cfg.CurrentAPI) + // Check for empty options list + if len(modelList) == 0 { + logger.Warn("empty model list for", "api", cfg.CurrentAPI, "localModelsLen", len(LocalModels), "orModelsLen", len(ORFreeModels)) + message := "No models available for selection" + if strings.Contains(cfg.CurrentAPI, "openrouter.ai") { + message = "No OpenRouter models available. Check token and connection." + } else if strings.Contains(cfg.CurrentAPI, "api.deepseek.com") { + message = "DeepSeek models should be available. Please report bug." + } else { + message = "No llama.cpp models loaded. Ensure llama.cpp server is running with models." + } + if err := notifyUser("Empty list", message); err != nil { + logger.Error("failed to send notification", "error", err) + } + return + } + // Create a list primitive + modelListWidget := tview.NewList().ShowSecondaryText(false). + SetSelectedBackgroundColor(tcell.ColorGray) + modelListWidget.SetTitle("Select Model").SetBorder(true) + // Find the current model index to set as selected + currentModelIndex := -1 + for i, model := range modelList { + if model == chatBody.Model { + currentModelIndex = i + } + modelListWidget.AddItem(model, "", 0, nil) + } + // Set the current selection if found + if currentModelIndex != -1 { + modelListWidget.SetCurrentItem(currentModelIndex) + } + modelListWidget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) { + // Update the model in both chatBody and config + chatBody.Model = mainText + cfg.CurrentModel = chatBody.Model + // Remove the popup page + pages.RemovePage("modelSelectionPopup") + // Update the status line to reflect the change + updateStatusLine() + }) + modelListWidget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + pages.RemovePage("modelSelectionPopup") + return nil + } + return event + }) + modal := func(p tview.Primitive, width, height int) tview.Primitive { + return tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(p, height, 1, true). + AddItem(nil, 0, 1, false), width, 1, true). + AddItem(nil, 0, 1, false) + } + // Add modal page and make it visible + pages.AddPage("modelSelectionPopup", modal(modelListWidget, 80, 20), true, true) + app.SetFocus(modelListWidget) +} + +// showAPILinkSelectionPopup creates a modal popup to select an API link +func showAPILinkSelectionPopup() { + // Prepare API links dropdown - ensure current API is in the list, avoid duplicates + apiLinks := make([]string, 0, len(cfg.ApiLinks)+1) + // Add current API first if it's not already in ApiLinks + foundCurrentAPI := false + for _, api := range cfg.ApiLinks { + if api == cfg.CurrentAPI { + foundCurrentAPI = true + } + apiLinks = append(apiLinks, api) + } + // If current API is not in the list, add it at the beginning + if !foundCurrentAPI { + apiLinks = make([]string, 0, len(cfg.ApiLinks)+1) + apiLinks = append(apiLinks, cfg.CurrentAPI) + apiLinks = append(apiLinks, cfg.ApiLinks...) + } + // Check for empty options list + if len(apiLinks) == 0 { + logger.Warn("no API links available for selection") + message := "No API links available. Please configure API links in your config file." + if err := notifyUser("Empty list", message); err != nil { + logger.Error("failed to send notification", "error", err) + } + return + } + // Create a list primitive + apiListWidget := tview.NewList().ShowSecondaryText(false). + SetSelectedBackgroundColor(tcell.ColorGray) + apiListWidget.SetTitle("Select API Link").SetBorder(true) + // Find the current API index to set as selected + currentAPIIndex := -1 + for i, api := range apiLinks { + if api == cfg.CurrentAPI { + currentAPIIndex = i + } + apiListWidget.AddItem(api, "", 0, nil) + } + // Set the current selection if found + if currentAPIIndex != -1 { + apiListWidget.SetCurrentItem(currentAPIIndex) + } + apiListWidget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) { + // Update the API in config + cfg.CurrentAPI = mainText + // Update model list based on new API + // Helper function to get model list for a given API (same as in props_table.go) + getModelListForAPI := func(api string) []string { + if strings.Contains(api, "api.deepseek.com/") { + return []string{"deepseek-chat", "deepseek-reasoner"} + } else if strings.Contains(api, "openrouter.ai") { + return ORFreeModels + } + // Assume local llama.cpp + refreshLocalModelsIfEmpty() + localModelsMu.RLock() + defer localModelsMu.RUnlock() + return LocalModels + } + newModelList := getModelListForAPI(cfg.CurrentAPI) + // Ensure chatBody.Model is in the new list; if not, set to first available model + if len(newModelList) > 0 && !slices.Contains(newModelList, chatBody.Model) { + chatBody.Model = newModelList[0] + cfg.CurrentModel = chatBody.Model + } + // Remove the popup page + pages.RemovePage("apiLinkSelectionPopup") + // Update the parser and status line to reflect the change + choseChunkParser() + updateStatusLine() + }) + apiListWidget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + pages.RemovePage("apiLinkSelectionPopup") + return nil + } + return event + }) + modal := func(p tview.Primitive, width, height int) tview.Primitive { + return tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(p, height, 1, true). + AddItem(nil, 0, 1, false), width, 1, true). + AddItem(nil, 0, 1, false) + } + // Add modal page and make it visible + pages.AddPage("apiLinkSelectionPopup", modal(apiListWidget, 80, 20), true, true) + app.SetFocus(apiListWidget) +} + +// showUserRoleSelectionPopup creates a modal popup to select a user role +func showUserRoleSelectionPopup() { + // Get the list of available roles + roles := listRolesWithUser() + // Check for empty options list + if len(roles) == 0 { + logger.Warn("no roles available for selection") + message := "No roles available for selection." + if err := notifyUser("Empty list", message); err != nil { + logger.Error("failed to send notification", "error", err) + } + return + } + // Create a list primitive + roleListWidget := tview.NewList().ShowSecondaryText(false). + SetSelectedBackgroundColor(tcell.ColorGray) + roleListWidget.SetTitle("Select User Role").SetBorder(true) + // Find the current role index to set as selected + currentRole := cfg.UserRole + if cfg.WriteNextMsgAs != "" { + currentRole = cfg.WriteNextMsgAs + } + currentRoleIndex := -1 + for i, role := range roles { + if strings.EqualFold(role, currentRole) { + currentRoleIndex = i + } + roleListWidget.AddItem(role, "", 0, nil) + } + // Set the current selection if found + if currentRoleIndex != -1 { + roleListWidget.SetCurrentItem(currentRoleIndex) + } + roleListWidget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) { + // Update the user role in config + cfg.WriteNextMsgAs = mainText + // role got switch, update textview with character specific context for user + filtered := filterMessagesForCharacter(chatBody.Messages, mainText) + textView.SetText(chatToText(filtered, cfg.ShowSys)) + // Remove the popup page + pages.RemovePage("userRoleSelectionPopup") + // Update the status line to reflect the change + updateStatusLine() + colorText() + }) + roleListWidget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + pages.RemovePage("userRoleSelectionPopup") + return nil + } + return event + }) + modal := func(p tview.Primitive, width, height int) tview.Primitive { + return tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(p, height, 1, true). + AddItem(nil, 0, 1, false), width, 1, true). + AddItem(nil, 0, 1, false) + } + // Add modal page and make it visible + pages.AddPage("userRoleSelectionPopup", modal(roleListWidget, 80, 20), true, true) + app.SetFocus(roleListWidget) +} + +// showBotRoleSelectionPopup creates a modal popup to select a bot role +func showBotRoleSelectionPopup() { + // Get the list of available roles + roles := listChatRoles() + if len(roles) == 0 { + logger.Warn("empty roles in chat") + } + if !strInSlice(cfg.AssistantRole, roles) { + roles = append(roles, cfg.AssistantRole) + } + // Check for empty options list + if len(roles) == 0 { + logger.Warn("no roles available for selection") + message := "No roles available for selection." + if err := notifyUser("Empty list", message); err != nil { + logger.Error("failed to send notification", "error", err) + } + return + } + // Create a list primitive + roleListWidget := tview.NewList().ShowSecondaryText(false). + SetSelectedBackgroundColor(tcell.ColorGray) + roleListWidget.SetTitle("Select Bot Role").SetBorder(true) + // Find the current role index to set as selected + currentRole := cfg.AssistantRole + if cfg.WriteNextMsgAsCompletionAgent != "" { + currentRole = cfg.WriteNextMsgAsCompletionAgent + } + currentRoleIndex := -1 + for i, role := range roles { + if strings.EqualFold(role, currentRole) { + currentRoleIndex = i + } + roleListWidget.AddItem(role, "", 0, nil) + } + // Set the current selection if found + if currentRoleIndex != -1 { + roleListWidget.SetCurrentItem(currentRoleIndex) + } + roleListWidget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) { + // Update the bot role in config + cfg.WriteNextMsgAsCompletionAgent = mainText + // Remove the popup page + pages.RemovePage("botRoleSelectionPopup") + // Update the status line to reflect the change + updateStatusLine() + }) + roleListWidget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + pages.RemovePage("botRoleSelectionPopup") + return nil + } + return event + }) + modal := func(p tview.Primitive, width, height int) tview.Primitive { + return tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(p, height, 1, true). + AddItem(nil, 0, 1, false), width, 1, true). + AddItem(nil, 0, 1, false) + } + // Add modal page and make it visible + pages.AddPage("botRoleSelectionPopup", modal(roleListWidget, 80, 20), true, true) + app.SetFocus(roleListWidget) +} diff --git a/props_table.go b/props_table.go index 50c8886..a7ad067 100644 --- a/props_table.go +++ b/props_table.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "slices" "strconv" "strings" "sync" @@ -53,7 +52,6 @@ func makePropsTable(props map[string]float32) *tview.Table { row++ // Store cell data for later use in selection functions cellData := make(map[string]*CellData) - var modelCellID string // will be set for the model selection row // Helper function to add a checkbox-like row addCheckboxRow := func(label string, initialValue bool, onChange func(bool)) { table.SetCell(row, 0, @@ -161,52 +159,6 @@ func makePropsTable(props map[string]float32) *tview.Table { defer localModelsMu.RUnlock() return LocalModels } - var modelRowIndex int // will be set before model row is added - // Prepare API links dropdown - ensure current API is first, avoid duplicates - apiLinks := make([]string, 0, len(cfg.ApiLinks)+1) - apiLinks = append(apiLinks, cfg.CurrentAPI) - for _, api := range cfg.ApiLinks { - if api != cfg.CurrentAPI { - apiLinks = append(apiLinks, api) - } - } - addListPopupRow("Select an api", apiLinks, cfg.CurrentAPI, func(option string) { - cfg.CurrentAPI = option - // Update model list based on new API - newModelList := getModelListForAPI(cfg.CurrentAPI) - if modelCellID != "" { - if data := cellData[modelCellID]; data != nil { - data.Options = newModelList - } - } - // Ensure chatBody.Model is in the new list; if not, set to first available model - if len(newModelList) > 0 && !slices.Contains(newModelList, chatBody.Model) { - chatBody.Model = newModelList[0] - cfg.CurrentModel = chatBody.Model - // Update the displayed cell text - need to find model row - // Search for model row by label - for r := 0; r < table.GetRowCount(); r++ { - if cell := table.GetCell(r, 0); cell != nil && cell.Text == "Select a model" { - if valueCell := table.GetCell(r, 1); valueCell != nil { - valueCell.SetText(chatBody.Model) - } - break - } - } - } - }) - // Prepare model list dropdown - modelRowIndex = row - modelCellID = fmt.Sprintf("listpopup_%d", modelRowIndex) - modelList := getModelListForAPI(cfg.CurrentAPI) - addListPopupRow("Select a model", modelList, chatBody.Model, func(option string) { - chatBody.Model = option - cfg.CurrentModel = chatBody.Model - }) - // Role selection dropdown - addListPopupRow("Write next message as", listRolesWithUser(), cfg.WriteNextMsgAs, func(option string) { - cfg.WriteNextMsgAs = option - }) // Add input fields addInputRow("New char to write msg as", "", func(text string) { if text != "" { @@ -77,16 +77,16 @@ var ( [yellow]Ctrl+n[white]: start a new chat [yellow]Ctrl+o[white]: open image file picker [yellow]Ctrl+p[white]: props edit form (min-p, dry, etc.) -[yellow]Ctrl+v[white]: switch between /completion and /chat api (if provided in config) +[yellow]Ctrl+v[white]: show API link selection popup to choose current API [yellow]Ctrl+r[white]: start/stop recording from your microphone (needs stt server or whisper binary) [yellow]Ctrl+t[white]: remove thinking (<think>) and tool messages from context (delete from chat) -[yellow]Ctrl+l[white]: rotate through free OpenRouter models (if openrouter api) or update connected model name (llamacpp) +[yellow]Ctrl+l[white]: show model selection popup to choose current model [yellow]Ctrl+k[white]: switch tool use (recommend tool use to llm after user msg) [yellow]Ctrl+a[white]: interrupt tts (needs tts server) [yellow]Ctrl+g[white]: open RAG file manager (load files for context retrieval) [yellow]Ctrl+y[white]: list loaded RAG files (view and manage loaded files) -[yellow]Ctrl+q[white]: cycle through mentioned chars in chat, to pick persona to send next msg as -[yellow]Ctrl+x[white]: cycle through mentioned chars in chat, to pick persona to send next msg as (for llm) +[yellow]Ctrl+q[white]: show user role selection popup to choose who sends next msg as +[yellow]Ctrl+x[white]: show bot role selection popup to choose which agent responds next [yellow]Alt+1[white]: toggle shell mode (execute commands locally) [yellow]Alt+2[white]: toggle auto-scrolling (for reading while LLM types) [yellow]Alt+3[white]: summarize chat history and start new chat with summary as tool response @@ -1026,30 +1026,8 @@ func init() { return nil } if event.Key() == tcell.KeyCtrlL { - // Check if the current API is an OpenRouter API - if strings.Contains(cfg.CurrentAPI, "openrouter.ai/api/v1/") { - // Rotate through OpenRouter free models - if len(ORFreeModels) > 0 { - currentORModelIndex = (currentORModelIndex + 1) % len(ORFreeModels) - chatBody.Model = ORFreeModels[currentORModelIndex] - cfg.CurrentModel = chatBody.Model - } - updateStatusLine() - } else { - localModelsMu.RLock() - if len(LocalModels) > 0 { - currentLocalModelIndex = (currentLocalModelIndex + 1) % len(LocalModels) - chatBody.Model = LocalModels[currentLocalModelIndex] - cfg.CurrentModel = chatBody.Model - } - localModelsMu.RUnlock() - updateStatusLine() - // // For non-OpenRouter APIs, use the old logic - // go func() { - // fetchLCPModelName() // blocks - // updateStatusLine() - // }() - } + // Show model selection popup instead of rotating models + showModelSelectionPopup() return nil } if event.Key() == tcell.KeyCtrlT { @@ -1061,29 +1039,8 @@ func init() { return nil } if event.Key() == tcell.KeyCtrlV { - // switch between API links using index-based rotation - if len(cfg.ApiLinks) == 0 { - // No API links to rotate through - return nil - } - // Find current API in the list to get the current index - currentIndex := -1 - for i, api := range cfg.ApiLinks { - if api == cfg.CurrentAPI { - currentIndex = i - break - } - } - // If current API is not in the list, start from beginning - // Otherwise, advance to next API in the list (with wrap-around) - if currentIndex == -1 { - currentAPIIndex = 0 - } else { - currentAPIIndex = (currentIndex + 1) % len(cfg.ApiLinks) - } - cfg.CurrentAPI = cfg.ApiLinks[currentAPIIndex] - choseChunkParser() - updateStatusLine() + // Show API link selection popup instead of rotating APIs + showAPILinkSelectionPopup() return nil } if event.Key() == tcell.KeyCtrlS { @@ -1179,54 +1136,13 @@ func init() { return nil } if event.Key() == tcell.KeyCtrlQ { - persona := cfg.UserRole - if cfg.WriteNextMsgAs != "" { - persona = cfg.WriteNextMsgAs - } - roles := listRolesWithUser() - for i, role := range roles { - if strings.EqualFold(role, persona) { - if i == len(roles)-1 { - cfg.WriteNextMsgAs = roles[0] // reached last, get first - persona = cfg.WriteNextMsgAs - break - } - cfg.WriteNextMsgAs = roles[i+1] // get next role - persona = cfg.WriteNextMsgAs - break - } - } - // role got switch, update textview with character specific context for user - filtered := filterMessagesForCharacter(chatBody.Messages, persona) - textView.SetText(chatToText(filtered, cfg.ShowSys)) - updateStatusLine() - colorText() + // Show user role selection popup instead of cycling through roles + showUserRoleSelectionPopup() return nil } if event.Key() == tcell.KeyCtrlX { - botPersona := cfg.AssistantRole - if cfg.WriteNextMsgAsCompletionAgent != "" { - botPersona = cfg.WriteNextMsgAsCompletionAgent - } - // roles := chatBody.ListRoles() - roles := listChatRoles() - if len(roles) == 0 { - logger.Warn("empty roles in chat") - } - if !strInSlice(cfg.AssistantRole, roles) { - roles = append(roles, cfg.AssistantRole) - } - for i, role := range roles { - if strings.EqualFold(role, botPersona) { - if i == len(roles)-1 { - cfg.WriteNextMsgAsCompletionAgent = roles[0] // reached last, get first - break - } - cfg.WriteNextMsgAsCompletionAgent = roles[i+1] // get next role - break - } - } - updateStatusLine() + // Show bot role selection popup instead of cycling through roles + showBotRoleSelectionPopup() return nil } if event.Key() == tcell.KeyCtrlG { |
