1121 lines
35 KiB
Go
1121 lines
35 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"gf-lt/models"
|
||
"image"
|
||
_ "image/jpeg"
|
||
_ "image/png"
|
||
"os"
|
||
"path"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gdamore/tcell/v2"
|
||
"github.com/rivo/tview"
|
||
)
|
||
|
||
func isFullScreenPageActive() bool {
|
||
name, _ := pages.GetFrontPage()
|
||
return name != "main"
|
||
}
|
||
|
||
var (
|
||
pages *tview.Pages
|
||
textArea *tview.TextArea
|
||
editArea *tview.TextArea
|
||
textView *tview.TextView
|
||
statusLineWidget *tview.TextView
|
||
helpView *tview.TextView
|
||
flex *tview.Flex
|
||
imgView *tview.Image
|
||
defaultImage = "sysprompts/llama.png"
|
||
indexPickWindow *tview.InputField
|
||
renameWindow *tview.InputField
|
||
roleEditWindow *tview.InputField
|
||
shellInput *tview.InputField
|
||
confirmModal *tview.Modal
|
||
confirmPageName = "confirm"
|
||
fullscreenMode bool
|
||
positionVisible bool = true
|
||
scrollToEndEnabled bool = true
|
||
// pages
|
||
historyPage = "historyPage"
|
||
agentPage = "agentPage"
|
||
editMsgPage = "editMsgPage"
|
||
roleEditPage = "roleEditPage"
|
||
helpPage = "helpPage"
|
||
renamePage = "renamePage"
|
||
RAGPage = "RAGPage"
|
||
RAGLoadedPage = "RAGLoadedPage"
|
||
propsPage = "propsPage"
|
||
codeBlockPage = "codeBlockPage"
|
||
imgPage = "imgPage"
|
||
filePickerPage = "filePicker"
|
||
exportDir = "chat_exports"
|
||
// For overlay search functionality
|
||
searchField *tview.InputField
|
||
searchPageName = "searchOverlay"
|
||
// help text
|
||
helpText = `
|
||
[yellow]Esc[white]: send msg
|
||
[yellow]PgUp/Down[white]: switch focus between input and chat widgets
|
||
[yellow]F1[white]: manage chats
|
||
[yellow]F2[white]: regen last
|
||
[yellow]F3[white]: delete last msg
|
||
[yellow]F4[white]: edit msg
|
||
[yellow]F5[white]: toggle fullscreen for input/chat window
|
||
[yellow]F6[white]: interrupt bot resp
|
||
[yellow]F7[white]: copy last msg to clipboard (linux xclip)
|
||
[yellow]F8[white]: copy n msg to clipboard (linux xclip)
|
||
[yellow]F9[white]: table to copy from; with all code blocks
|
||
[yellow]F10[white]: switch if LLM will respond on this message (for user to write multiple messages in a row)
|
||
[yellow]F11[white]: import json chat file
|
||
[yellow]F12[white]: show this help page
|
||
[yellow]Ctrl+w[white]: resume generation on the last msg
|
||
[yellow]Ctrl+s[white]: load new char/agent
|
||
[yellow]Ctrl+e[white]: export chat to json file
|
||
[yellow]Ctrl+c[white]: close programm
|
||
[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]: 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]: (un)collapse tool messages
|
||
[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]Alt+0[white]: replay last message via 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]: 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
|
||
[yellow]Alt+4[white]: edit msg role
|
||
[yellow]Alt+5[white]: toggle system and tool messages display
|
||
[yellow]Alt+6[white]: toggle status line visibility
|
||
[yellow]Alt+7[white]: toggle role injection (inject role in messages)
|
||
[yellow]Alt+8[white]: show char img or last picked img
|
||
[yellow]Alt+9[white]: warm up (load) selected llama.cpp model
|
||
[yellow]Alt+t[white]: toggle thinking blocks visibility (collapse/expand <think> blocks)
|
||
[yellow]Ctrl+t[white]: toggle tool call/response visibility (collapse/expand tool calls and non-shell tool responses)
|
||
[yellow]Alt+i[white]: show colorscheme selection popup
|
||
|
||
=== scrolling chat window (some keys similar to vim) ===
|
||
[yellow]arrows up/down and j/k[white]: scroll up and down
|
||
[yellow]gg/G[white]: jump to the begging / end of the chat
|
||
[yellow]/[white]: start searching for text
|
||
[yellow]n[white]: go to next search result
|
||
[yellow]N[white]: go to previous search result
|
||
|
||
=== tables (chat history, agent pick, file pick, properties) ===
|
||
[yellow]x[white]: to exit the table page
|
||
|
||
=== filepicker ===
|
||
[yellow]s[white]: (in file picker) set current dir as FilePickerDir
|
||
[yellow]x[white]: to exit
|
||
|
||
=== shell mode ===
|
||
[yellow]@match->Tab[white]: file completion with relative paths (recursive, depth 3, max 50 files)
|
||
|
||
=== status line ===
|
||
%s
|
||
|
||
Press <Enter> or 'x' to return
|
||
`
|
||
)
|
||
|
||
func setShellMode(enabled bool) {
|
||
shellMode = enabled
|
||
go func() {
|
||
app.QueueUpdateDraw(func() {
|
||
updateFlexLayout()
|
||
})
|
||
}()
|
||
}
|
||
|
||
// showToast displays a temporary message in the top‑right corner.
|
||
// It auto‑hides after 3 seconds and disappears when clicked.
|
||
func showToast(title, message string) {
|
||
sanitize := func(s string, maxLen int) string {
|
||
sanitized := strings.Map(func(r rune) rune {
|
||
if r < 32 && r != '\t' {
|
||
return -1
|
||
}
|
||
return r
|
||
}, s)
|
||
if len(sanitized) > maxLen {
|
||
sanitized = sanitized[:maxLen-3] + "..."
|
||
}
|
||
return sanitized
|
||
}
|
||
title = sanitize(title, 50)
|
||
message = sanitize(message, 197)
|
||
notification := tview.NewTextView().
|
||
SetTextAlign(tview.AlignCenter).
|
||
SetDynamicColors(true).
|
||
SetRegions(true).
|
||
SetText(fmt.Sprintf("[yellow]%s[-]\n", message)).
|
||
SetChangedFunc(func() {
|
||
app.Draw()
|
||
})
|
||
notification.SetTitleAlign(tview.AlignLeft).
|
||
SetBorder(true).
|
||
SetTitle(title)
|
||
// Wrap it in a full‑screen Flex to position it in the top‑right corner.
|
||
// Outer Flex (row) pushes content to the top; inner Flex (column) pushes to the right.
|
||
background := tview.NewFlex().SetDirection(tview.FlexRow).
|
||
AddItem(nil, 0, 1, false). // top spacer
|
||
AddItem(tview.NewFlex().SetDirection(tview.FlexColumn).
|
||
AddItem(nil, 0, 1, false). // left spacer
|
||
AddItem(notification, 40, 1, true), // notification width 40
|
||
5, 1, false) // notification height 5
|
||
// Generate a unique page name (e.g., using timestamp) to allow multiple toasts.
|
||
pageName := fmt.Sprintf("toast-%d", time.Now().UnixNano())
|
||
pages.AddPage(pageName, background, true, true)
|
||
// Auto‑dismiss after 3 seconds.
|
||
time.AfterFunc(3*time.Second, func() {
|
||
app.QueueUpdateDraw(func() {
|
||
if pages.HasPage(pageName) {
|
||
pages.RemovePage(pageName)
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
func init() {
|
||
// Start background goroutine to update model color cache
|
||
startModelColorUpdater()
|
||
tview.Styles = colorschemes["default"]
|
||
app = tview.NewApplication()
|
||
pages = tview.NewPages()
|
||
shellInput = tview.NewInputField().
|
||
SetLabel(fmt.Sprintf("[%s]$ ", cfg.FilePickerDir)). // dynamic prompt
|
||
SetFieldWidth(0).
|
||
SetDoneFunc(func(key tcell.Key) {
|
||
if key == tcell.KeyEnter {
|
||
cmd := shellInput.GetText()
|
||
if cmd != "" {
|
||
executeCommandAndDisplay(cmd)
|
||
}
|
||
shellInput.SetText("")
|
||
}
|
||
})
|
||
// Copy your file completion logic to shellInput's InputCapture
|
||
shellInput.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||
if !shellMode {
|
||
return event
|
||
}
|
||
// Handle Up arrow for history previous
|
||
if event.Key() == tcell.KeyUp {
|
||
if len(shellHistory) > 0 {
|
||
if shellHistoryPos < len(shellHistory)-1 {
|
||
shellHistoryPos++
|
||
shellInput.SetText(shellHistory[len(shellHistory)-1-shellHistoryPos])
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
// Handle Down arrow for history next
|
||
if event.Key() == tcell.KeyDown {
|
||
if shellHistoryPos > 0 {
|
||
shellHistoryPos--
|
||
shellInput.SetText(shellHistory[len(shellHistory)-1-shellHistoryPos])
|
||
} else if shellHistoryPos == 0 {
|
||
shellHistoryPos = -1
|
||
shellInput.SetText("")
|
||
}
|
||
return nil
|
||
}
|
||
// Reset history position when user types
|
||
if event.Key() == tcell.KeyRune {
|
||
shellHistoryPos = -1
|
||
}
|
||
// Handle Tab key for @ file completion
|
||
if event.Key() == tcell.KeyTab {
|
||
currentText := shellInput.GetText()
|
||
atIndex := strings.LastIndex(currentText, "@")
|
||
if atIndex >= 0 {
|
||
filter := currentText[atIndex+1:]
|
||
showShellFileCompletionPopup(filter)
|
||
}
|
||
return nil
|
||
}
|
||
return event
|
||
})
|
||
confirmModal = tview.NewModal().
|
||
SetText("You are trying to send an empty message.\nIt makes sense if the last message in the chat is from you.\nAre you sure?").
|
||
AddButtons([]string{"Yes", "No"}).
|
||
SetButtonBackgroundColor(tcell.ColorBlack).
|
||
SetButtonTextColor(tcell.ColorWhite).
|
||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||
if buttonLabel == "Yes" {
|
||
persona := cfg.UserRole
|
||
if cfg.WriteNextMsgAs != "" {
|
||
persona = cfg.WriteNextMsgAs
|
||
}
|
||
chatRoundChan <- &models.ChatRoundReq{Role: persona, UserMsg: ""}
|
||
} // In both Yes and No, go back to the main page
|
||
pages.SwitchToPage("main") // or whatever your main page is named
|
||
})
|
||
confirmModal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||
if event.Key() == tcell.KeyRune {
|
||
switch event.Rune() {
|
||
case 'y', 'Y':
|
||
persona := cfg.UserRole
|
||
if cfg.WriteNextMsgAs != "" {
|
||
persona = cfg.WriteNextMsgAs
|
||
}
|
||
chatRoundChan <- &models.ChatRoundReq{Role: persona, UserMsg: ""}
|
||
pages.SwitchToPage("main")
|
||
return nil
|
||
case 'n', 'N', 'x', 'X':
|
||
pages.SwitchToPage("main")
|
||
return nil
|
||
}
|
||
}
|
||
return event
|
||
})
|
||
textArea = tview.NewTextArea().
|
||
SetPlaceholder("input is multiline; press <Enter> to start the next line;\npress <Esc> to send the message.")
|
||
textArea.SetBorder(true).SetTitle("input")
|
||
textView = tview.NewTextView().
|
||
SetDynamicColors(true).
|
||
SetRegions(true).
|
||
SetChangedFunc(func() {
|
||
app.Draw()
|
||
})
|
||
//
|
||
flex = tview.NewFlex().SetDirection(tview.FlexRow).
|
||
AddItem(textView, 0, 40, false).
|
||
AddItem(textArea, 0, 10, true) // Restore original height
|
||
if positionVisible {
|
||
flex.AddItem(statusLineWidget, 0, 2, false)
|
||
}
|
||
// textView.SetBorder(true).SetTitle("chat")
|
||
textView.SetDoneFunc(func(key tcell.Key) {
|
||
if key == tcell.KeyEnter {
|
||
if len(searchResults) > 0 { // Check if a search is active
|
||
hideSearchBar() // Hide the search bar if visible
|
||
searchResults = nil // Clear search results
|
||
searchResultLengths = nil // Clear search result lengths
|
||
originalTextForSearch = ""
|
||
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys)) // Reset text without search regions
|
||
colorText() // Apply normal chat coloring
|
||
} else {
|
||
// Original logic if no search is active
|
||
currentSelection := textView.GetHighlights()
|
||
if len(currentSelection) > 0 {
|
||
textView.Highlight()
|
||
} else {
|
||
textView.Highlight("0").ScrollToHighlight()
|
||
}
|
||
}
|
||
}
|
||
})
|
||
textView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||
// Handle vim-like navigation in TextView
|
||
if event.Key() == tcell.KeyRune {
|
||
switch event.Rune() {
|
||
case 'j':
|
||
// For line down
|
||
return event
|
||
case 'k':
|
||
// For line up
|
||
return event
|
||
case 'g':
|
||
// Go to beginning
|
||
textView.ScrollToBeginning()
|
||
return nil
|
||
case 'G':
|
||
// Go to end
|
||
textView.ScrollToEnd()
|
||
return nil
|
||
case '/':
|
||
// Search functionality - show search bar
|
||
showSearchBar()
|
||
return nil
|
||
case 'n':
|
||
// Next search result
|
||
searchNext()
|
||
return nil
|
||
case 'N':
|
||
// Previous search result
|
||
searchPrev()
|
||
return nil
|
||
}
|
||
}
|
||
return event
|
||
})
|
||
focusSwitcher[textArea] = textView
|
||
focusSwitcher[textView] = textArea
|
||
statusLineWidget = tview.NewTextView().
|
||
SetDynamicColors(true).
|
||
SetTextAlign(tview.AlignCenter)
|
||
// // vertical text center alignment
|
||
// statusLineWidget.SetDrawFunc(func(screen tcell.Screen, x, y, w, h int) (int, int, int, int) {
|
||
// y += h / 2
|
||
// return x, y, w, h
|
||
// })
|
||
// Initially set up flex without search bar
|
||
flex = tview.NewFlex().SetDirection(tview.FlexRow).
|
||
AddItem(textView, 0, 40, false).
|
||
AddItem(textArea, 0, 10, true) // Restore original height
|
||
if positionVisible {
|
||
flex.AddItem(statusLineWidget, 0, 2, false)
|
||
}
|
||
editArea = tview.NewTextArea().
|
||
SetPlaceholder("Replace msg...")
|
||
editArea.SetBorder(true).SetTitle("input")
|
||
editArea.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||
// if event.Key() == tcell.KeyEscape && editMode {
|
||
if event.Key() == tcell.KeyEscape {
|
||
defer colorText()
|
||
editedMsg := editArea.GetText()
|
||
if editedMsg == "" {
|
||
showToast("edit", "no edit provided")
|
||
pages.RemovePage(editMsgPage)
|
||
return nil
|
||
}
|
||
chatBody.Messages[selectedIndex].SetText(editedMsg)
|
||
// change textarea
|
||
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
|
||
pages.RemovePage(editMsgPage)
|
||
editMode = false
|
||
return nil
|
||
}
|
||
return event
|
||
})
|
||
indexPickWindow = tview.NewInputField().
|
||
SetLabel("Enter a msg index: ").
|
||
SetFieldWidth(4).
|
||
SetAcceptanceFunc(tview.InputFieldInteger).
|
||
SetDoneFunc(func(key tcell.Key) {
|
||
hideIndexBar()
|
||
// colorText()
|
||
// updateStatusLine()
|
||
})
|
||
roleEditWindow = tview.NewInputField().
|
||
SetLabel("Enter new role: ").
|
||
SetPlaceholder("e.g., user, assistant, system, tool").
|
||
SetDoneFunc(func(key tcell.Key) {
|
||
switch key {
|
||
case tcell.KeyEnter:
|
||
newRole := roleEditWindow.GetText()
|
||
if newRole == "" {
|
||
showToast("edit", "no role provided")
|
||
pages.RemovePage(roleEditPage)
|
||
return
|
||
}
|
||
if selectedIndex >= 0 && selectedIndex < len(chatBody.Messages) {
|
||
chatBody.Messages[selectedIndex].Role = newRole
|
||
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
|
||
colorText()
|
||
pages.RemovePage(roleEditPage)
|
||
}
|
||
case tcell.KeyEscape:
|
||
pages.RemovePage(roleEditPage)
|
||
}
|
||
})
|
||
indexPickWindow.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||
switch event.Key() {
|
||
case tcell.KeyBackspace:
|
||
return event
|
||
case tcell.KeyEscape:
|
||
// Hide the index overlay when Escape is pressed
|
||
hideIndexBar()
|
||
return nil
|
||
case tcell.KeyEnter:
|
||
si := indexPickWindow.GetText()
|
||
siInt, err := strconv.Atoi(si)
|
||
if err != nil {
|
||
logger.Error("failed to convert provided index", "error", err, "si", si)
|
||
showToast("cancel", "no index provided, copying user input")
|
||
if err := copyToClipboard(textArea.GetText()); err != nil {
|
||
logger.Error("failed to copy to clipboard", "error", err)
|
||
}
|
||
hideIndexBar() // Hide overlay instead of removing page directly
|
||
return nil
|
||
}
|
||
selectedIndex = siInt
|
||
if len(chatBody.Messages)-1 < selectedIndex || selectedIndex < 0 {
|
||
msg := "chosen index is out of bounds, will copy user input"
|
||
logger.Warn(msg, "index", selectedIndex)
|
||
showToast("error", msg)
|
||
if err := copyToClipboard(textArea.GetText()); err != nil {
|
||
logger.Error("failed to copy to clipboard", "error", err)
|
||
}
|
||
hideIndexBar() // Hide overlay instead of removing page directly
|
||
return nil
|
||
}
|
||
m := chatBody.Messages[selectedIndex]
|
||
switch {
|
||
case roleEditMode:
|
||
hideIndexBar() // Hide overlay first
|
||
// Set the current role as the default text in the input field
|
||
roleEditWindow.SetText(m.Role)
|
||
pages.AddPage(roleEditPage, roleEditWindow, true, true)
|
||
roleEditMode = false // Reset the flag
|
||
case editMode:
|
||
hideIndexBar() // Hide overlay first
|
||
pages.AddPage(editMsgPage, editArea, true, true)
|
||
editArea.SetText(m.GetText(), true)
|
||
default:
|
||
msgText := m.GetText()
|
||
if err := copyToClipboard(msgText); err != nil {
|
||
logger.Error("failed to copy to clipboard", "error", err)
|
||
}
|
||
previewLen := min(30, len(msgText))
|
||
notification := fmt.Sprintf("msg '%s' was copied to the clipboard", msgText[:previewLen])
|
||
showToast("copied", notification)
|
||
hideIndexBar() // Hide overlay after copying
|
||
}
|
||
return nil
|
||
default:
|
||
return event
|
||
}
|
||
})
|
||
//
|
||
renameWindow = tview.NewInputField().
|
||
SetLabel("Enter a msg index: ").
|
||
SetFieldWidth(20).
|
||
SetAcceptanceFunc(tview.InputFieldMaxLength(100)).
|
||
SetDoneFunc(func(key tcell.Key) {
|
||
pages.RemovePage(renamePage)
|
||
})
|
||
renameWindow.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||
if event.Key() == tcell.KeyEnter {
|
||
nname := renameWindow.GetText()
|
||
if nname == "" {
|
||
return event
|
||
}
|
||
currentChat := chatMap[activeChatName]
|
||
delete(chatMap, activeChatName)
|
||
currentChat.Name = nname
|
||
activeChatName = nname
|
||
chatMap[activeChatName] = currentChat
|
||
_, err := store.UpsertChat(currentChat)
|
||
if err != nil {
|
||
logger.Error("failed to upsert chat", "error", err, "chat", currentChat)
|
||
}
|
||
notification := fmt.Sprintf("renamed chat to '%s'", activeChatName)
|
||
showToast("renamed", notification)
|
||
}
|
||
return event
|
||
})
|
||
//
|
||
searchField = tview.NewInputField().
|
||
SetPlaceholder("Search... (Enter: search)").
|
||
SetDoneFunc(func(key tcell.Key) {
|
||
if key == tcell.KeyEnter {
|
||
term := searchField.GetText()
|
||
if term == "" {
|
||
// If the search term is empty, cancel the search
|
||
hideSearchBar()
|
||
searchResults = nil
|
||
searchResultLengths = nil
|
||
originalTextForSearch = ""
|
||
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
|
||
colorText()
|
||
return
|
||
} else {
|
||
performSearch(term)
|
||
// Keep focus on textView after search
|
||
app.SetFocus(textView)
|
||
hideSearchBar()
|
||
}
|
||
}
|
||
})
|
||
searchField.SetBorder(true).SetTitle("Search")
|
||
// Note: Initially hide the search field (handled by not showing it in the layout)
|
||
//
|
||
helpView = tview.NewTextView().SetDynamicColors(true).
|
||
SetText(fmt.Sprintf(helpText, makeStatusLine())).
|
||
SetDoneFunc(func(key tcell.Key) {
|
||
pages.RemovePage(helpPage)
|
||
})
|
||
helpView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||
if event.Key() == tcell.KeyEnter {
|
||
return event
|
||
}
|
||
if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
|
||
pages.RemovePage(helpPage)
|
||
return nil
|
||
}
|
||
// Allow scrolling keys to pass through to the TextView
|
||
switch event.Key() {
|
||
case tcell.KeyUp, tcell.KeyDown,
|
||
tcell.KeyPgUp, tcell.KeyPgDn,
|
||
tcell.KeyHome, tcell.KeyEnd:
|
||
return event
|
||
}
|
||
if event.Key() == tcell.KeyRune {
|
||
switch event.Rune() {
|
||
case 'j', 'k', 'g', 'G':
|
||
return event
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
//
|
||
imgView = tview.NewImage()
|
||
imgView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||
if event.Key() == tcell.KeyEnter {
|
||
pages.RemovePage(imgPage)
|
||
return event
|
||
}
|
||
if isASCII(string(event.Rune())) {
|
||
pages.RemovePage(imgPage)
|
||
return event
|
||
}
|
||
return nil
|
||
})
|
||
//
|
||
textArea.SetMovedFunc(updateStatusLine)
|
||
updateStatusLine()
|
||
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
|
||
colorText()
|
||
if scrollToEndEnabled {
|
||
textView.ScrollToEnd()
|
||
}
|
||
// init sysmap
|
||
_, err := initSysCards()
|
||
if err != nil {
|
||
logger.Error("failed to init sys cards", "error", err)
|
||
}
|
||
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||
if event.Key() == tcell.KeyRune && event.Rune() == '5' && event.Modifiers()&tcell.ModAlt != 0 {
|
||
// switch cfg.ShowSys
|
||
cfg.ShowSys = !cfg.ShowSys
|
||
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
|
||
colorText()
|
||
}
|
||
if event.Key() == tcell.KeyRune && event.Rune() == '3' && event.Modifiers()&tcell.ModAlt != 0 {
|
||
go summarizeAndStartNewChat()
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyRune && event.Rune() == '6' && event.Modifiers()&tcell.ModAlt != 0 {
|
||
// toggle status line visibility
|
||
if name, _ := pages.GetFrontPage(); name != "main" {
|
||
return event
|
||
}
|
||
positionVisible = !positionVisible
|
||
updateFlexLayout()
|
||
}
|
||
if event.Key() == tcell.KeyRune && event.Rune() == '2' && event.Modifiers()&tcell.ModAlt != 0 {
|
||
// toggle auto-scrolling
|
||
scrollToEndEnabled = !scrollToEndEnabled
|
||
status := "disabled"
|
||
if scrollToEndEnabled {
|
||
status = "enabled"
|
||
}
|
||
showToast("autoscroll", "Auto-scrolling "+status)
|
||
updateStatusLine()
|
||
}
|
||
// Handle Alt+7 to toggle injectRole
|
||
if event.Key() == tcell.KeyRune && event.Rune() == '7' && event.Modifiers()&tcell.ModAlt != 0 {
|
||
injectRole = !injectRole
|
||
updateStatusLine()
|
||
}
|
||
// Handle Alt+T to toggle thinking block visibility
|
||
if event.Key() == tcell.KeyRune && event.Rune() == 't' && event.Modifiers()&tcell.ModAlt != 0 {
|
||
thinkingCollapsed = !thinkingCollapsed
|
||
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
|
||
colorText()
|
||
status := "expanded"
|
||
if thinkingCollapsed {
|
||
status = "collapsed"
|
||
}
|
||
showToast("thinking", "Thinking blocks "+status)
|
||
return nil
|
||
}
|
||
// Handle Ctrl+T to toggle tool call/response visibility
|
||
if event.Key() == tcell.KeyCtrlT {
|
||
toolCollapsed = !toolCollapsed
|
||
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
|
||
colorText()
|
||
status := "expanded"
|
||
if toolCollapsed {
|
||
status = "collapsed"
|
||
}
|
||
showToast("tools", "Tool calls/responses "+status)
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyRune && event.Rune() == 'i' && event.Modifiers()&tcell.ModAlt != 0 {
|
||
if isFullScreenPageActive() {
|
||
return event
|
||
}
|
||
showColorschemeSelectionPopup()
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyF1 {
|
||
// chatList, err := loadHistoryChats()
|
||
chatList, err := store.GetChatByChar(cfg.AssistantRole)
|
||
if err != nil {
|
||
logger.Error("failed to load chat history", "error", err)
|
||
return nil
|
||
}
|
||
// Check if there are no chats for this agent
|
||
if len(chatList) == 0 {
|
||
notification := "no chats found for agent: " + cfg.AssistantRole
|
||
showToast("info", notification)
|
||
return nil
|
||
}
|
||
chatMap := make(map[string]models.Chat)
|
||
// nameList := make([]string, len(chatList))
|
||
for _, chat := range chatList {
|
||
// nameList[i] = chat.Name
|
||
chatMap[chat.Name] = chat
|
||
}
|
||
chatActTable := makeChatTable(chatMap)
|
||
pages.AddPage(historyPage, chatActTable, true, true)
|
||
colorText()
|
||
updateStatusLine()
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyF2 && !botRespMode {
|
||
// regen last msg
|
||
if len(chatBody.Messages) == 0 {
|
||
showToast("info", "no messages to regenerate")
|
||
return nil
|
||
}
|
||
chatBody.Messages = chatBody.Messages[:len(chatBody.Messages)-1]
|
||
// there is no case where user msg is regenerated
|
||
// lastRole := chatBody.Messages[len(chatBody.Messages)-1].Role
|
||
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
|
||
// go chatRound("", cfg.UserRole, textView, true, false)
|
||
if cfg.TTS_ENABLED {
|
||
TTSDoneChan <- true
|
||
}
|
||
chatRoundChan <- &models.ChatRoundReq{Role: cfg.UserRole, Regen: true}
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyF3 && !botRespMode {
|
||
// delete last msg
|
||
// check textarea text; if it ends with bot icon delete only icon:
|
||
text := textView.GetText(true)
|
||
assistantIcon := roleToIcon(cfg.AssistantRole)
|
||
if strings.HasSuffix(text, assistantIcon) {
|
||
logger.Debug("deleting assistant icon", "icon", assistantIcon)
|
||
textView.SetText(strings.TrimSuffix(text, assistantIcon))
|
||
colorText()
|
||
return nil
|
||
}
|
||
if len(chatBody.Messages) == 0 {
|
||
showToast("info", "no messages to delete")
|
||
return nil
|
||
}
|
||
chatBody.Messages = chatBody.Messages[:len(chatBody.Messages)-1]
|
||
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
|
||
if cfg.TTS_ENABLED {
|
||
TTSDoneChan <- true
|
||
}
|
||
colorText()
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyF4 {
|
||
// edit msg - show index input as overlay at top
|
||
editMode = true
|
||
showIndexBar()
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyRune && event.Modifiers() == tcell.ModAlt && event.Rune() == '4' {
|
||
// edit msg role - show index input as overlay at top
|
||
editMode = false // Reset edit mode to false to handle role editing
|
||
showIndexBar()
|
||
// Set a flag to indicate we're in role edit mode
|
||
roleEditMode = true
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyF5 {
|
||
// toggle fullscreen
|
||
fullscreenMode = !fullscreenMode
|
||
focused := app.GetFocus()
|
||
if fullscreenMode {
|
||
if focused == textArea || focused == textView {
|
||
flex.Clear()
|
||
flex.AddItem(focused, 0, 1, true)
|
||
} else {
|
||
// if focus is not on textarea or textview, cancel fullscreen
|
||
fullscreenMode = false
|
||
}
|
||
} else {
|
||
// focused is the fullscreened widget here
|
||
updateFlexLayout()
|
||
}
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyF6 {
|
||
interruptResp = true
|
||
botRespMode = false
|
||
toolRunningMode = false
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyF7 {
|
||
// copy msg to clipboard
|
||
editMode = false
|
||
m := chatBody.Messages[len(chatBody.Messages)-1]
|
||
msgText := m.GetText()
|
||
if err := copyToClipboard(msgText); err != nil {
|
||
logger.Error("failed to copy to clipboard", "error", err)
|
||
}
|
||
previewLen := min(30, len(msgText))
|
||
notification := fmt.Sprintf("msg '%s' was copied to the clipboard", msgText[:previewLen])
|
||
showToast("copied", notification)
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyF8 {
|
||
// copy msg to clipboard
|
||
editMode = false
|
||
showIndexBar()
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyF9 {
|
||
// table of codeblocks to copy
|
||
text := textView.GetText(false)
|
||
cb := codeBlockRE.FindAllString(text, -1)
|
||
if len(cb) == 0 {
|
||
showToast("notify", "no code blocks in chat")
|
||
return nil
|
||
}
|
||
table := makeCodeBlockTable(cb)
|
||
pages.AddPage(codeBlockPage, table, true, true)
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyF10 {
|
||
cfg.SkipLLMResp = !cfg.SkipLLMResp
|
||
updateStatusLine()
|
||
}
|
||
if event.Key() == tcell.KeyF11 {
|
||
// read files in chat_exports
|
||
filelist, err := os.ReadDir(exportDir)
|
||
if err != nil {
|
||
showToast("failed to load exports", err.Error())
|
||
return nil
|
||
}
|
||
fli := []string{}
|
||
for _, f := range filelist {
|
||
if f.IsDir() || !strings.HasSuffix(f.Name(), ".json") {
|
||
continue
|
||
}
|
||
fpath := path.Join(exportDir, f.Name())
|
||
fli = append(fli, fpath)
|
||
}
|
||
// check error
|
||
exportsTable := makeImportChatTable(fli)
|
||
pages.AddPage(historyPage, exportsTable, true, true)
|
||
updateStatusLine()
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyF12 {
|
||
// help window cheatsheet
|
||
// Update help text with current status before showing
|
||
helpView.SetText(fmt.Sprintf(helpText, makeStatusLine()))
|
||
pages.AddPage(helpPage, helpView, true, true)
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyCtrlE {
|
||
// export loaded chat into json file
|
||
if err := exportChat(); err != nil {
|
||
logger.Error("failed to export chat;", "error", err, "chat_name", activeChatName)
|
||
return nil
|
||
}
|
||
showToast("exported chat", "chat: "+activeChatName+" was exported")
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyCtrlP {
|
||
propsTable := makePropsTable(defaultLCPProps)
|
||
pages.AddPage(propsPage, propsTable, true, true)
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyCtrlN {
|
||
startNewChat(true)
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyCtrlO {
|
||
// open file picker
|
||
filePicker := makeFilePicker()
|
||
pages.AddPage(filePickerPage, filePicker, true, true)
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyCtrlL {
|
||
if isFullScreenPageActive() {
|
||
return event
|
||
}
|
||
// Show model selection popup instead of rotating models
|
||
showModelSelectionPopup()
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyCtrlV {
|
||
if isFullScreenPageActive() {
|
||
return event
|
||
}
|
||
// Show API link selection popup instead of rotating APIs
|
||
showAPILinkSelectionPopup()
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyCtrlS {
|
||
// switch sys prompt
|
||
labels, err := initSysCards()
|
||
if err != nil {
|
||
logger.Error("failed to read sys dir", "error", err)
|
||
showToast("error", "failed to read: "+cfg.SysDir)
|
||
return nil
|
||
}
|
||
at := makeAgentTable(labels)
|
||
// sysModal.AddButtons(labels)
|
||
// load all chars
|
||
pages.AddPage(agentPage, at, true, true)
|
||
updateStatusLine()
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyCtrlK {
|
||
// add message from tools
|
||
cfg.ToolUse = !cfg.ToolUse
|
||
updateToolCapabilities()
|
||
updateStatusLine()
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyRune && event.Rune() == '8' && event.Modifiers()&tcell.ModAlt != 0 {
|
||
// show image - check for attached image first, then fall back to agent image
|
||
if lastImg != "" {
|
||
// Load the attached image
|
||
file, err := os.Open(lastImg)
|
||
if err != nil {
|
||
logger.Error("failed to open attached image", "path", lastImg, "error", err)
|
||
// Fall back to showing agent image
|
||
if err := loadImage(); err != nil {
|
||
logger.Warn("failed to load agent image", "error", err)
|
||
}
|
||
} else {
|
||
defer file.Close()
|
||
img, _, err := image.Decode(file)
|
||
if err != nil {
|
||
logger.Error("failed to decode attached image", "path", lastImg, "error", err)
|
||
// Fall back to showing agent image
|
||
if err := loadImage(); err != nil {
|
||
logger.Warn("failed to load agent image", "error", err)
|
||
}
|
||
} else {
|
||
imgView.SetImage(img)
|
||
}
|
||
}
|
||
} else {
|
||
// No attached image, show agent image as before
|
||
if err := loadImage(); err != nil {
|
||
logger.Warn("failed to load agent image", "error", err)
|
||
}
|
||
}
|
||
pages.AddPage(imgPage, imgView, true, true)
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyCtrlR && cfg.STT_ENABLED {
|
||
defer updateStatusLine()
|
||
if asr.IsRecording() {
|
||
userSpeech, err := asr.StopRecording()
|
||
if err != nil {
|
||
msg := "failed to inference user speech; error:" + err.Error()
|
||
logger.Error(msg)
|
||
showToast("stt error", msg)
|
||
return nil
|
||
}
|
||
if userSpeech != "" {
|
||
// append indtead of replacing
|
||
prevText := textArea.GetText()
|
||
textArea.SetText(prevText+userSpeech, true)
|
||
} else {
|
||
logger.Warn("empty user speech")
|
||
}
|
||
return nil
|
||
}
|
||
if err := asr.StartRecording(); err != nil {
|
||
logger.Error("failed to start recording user speech", "error", err)
|
||
return nil
|
||
}
|
||
}
|
||
// I need keybind for tts to shut up
|
||
if event.Key() == tcell.KeyCtrlA && cfg.TTS_ENABLED {
|
||
TTSDoneChan <- true
|
||
}
|
||
if event.Key() == tcell.KeyRune && event.Rune() == '0' && event.Modifiers()&tcell.ModAlt != 0 && cfg.TTS_ENABLED {
|
||
if len(chatBody.Messages) > 0 {
|
||
// Stop any currently playing TTS first
|
||
TTSDoneChan <- true
|
||
lastMsg := chatBody.Messages[len(chatBody.Messages)-1]
|
||
cleanedText := models.CleanText(lastMsg.GetText())
|
||
if cleanedText != "" {
|
||
// nolint: errcheck
|
||
go orator.Speak(cleanedText)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyCtrlW {
|
||
// INFO: continue bot/text message
|
||
// without new role
|
||
lastRole := chatBody.Messages[len(chatBody.Messages)-1].Role
|
||
// go chatRound("", lastRole, textView, false, true)
|
||
chatRoundChan <- &models.ChatRoundReq{Role: lastRole, Resume: true}
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyCtrlQ {
|
||
if isFullScreenPageActive() {
|
||
return event
|
||
}
|
||
// Show user role selection popup instead of cycling through roles
|
||
showUserRoleSelectionPopup()
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyCtrlX {
|
||
if isFullScreenPageActive() {
|
||
return event
|
||
}
|
||
// Show bot role selection popup instead of cycling through roles
|
||
showBotRoleSelectionPopup()
|
||
return nil
|
||
}
|
||
// INFO: shutdown
|
||
if event.Key() == tcell.KeyCtrlC {
|
||
logger.Info("caught Ctrl+C via tcell event")
|
||
go func() {
|
||
if err := pwShutDown(); err != nil {
|
||
logger.Error("shutdown failed", "err", err)
|
||
}
|
||
app.Stop()
|
||
}()
|
||
return nil // swallow the event
|
||
}
|
||
if event.Key() == tcell.KeyCtrlG {
|
||
// cfg.RAGDir is the directory with files to use with RAG
|
||
// rag load
|
||
// menu of the text files from defined rag directory
|
||
files, err := os.ReadDir(cfg.RAGDir)
|
||
if err != nil {
|
||
// Check if the error is because the directory doesn't exist
|
||
if os.IsNotExist(err) {
|
||
// Create the RAG directory if it doesn't exist
|
||
if mkdirErr := os.MkdirAll(cfg.RAGDir, 0755); mkdirErr != nil {
|
||
logger.Error("failed to create RAG directory", "dir", cfg.RAGDir, "error", mkdirErr)
|
||
showToast("failed to create RAG directory", mkdirErr.Error())
|
||
return nil
|
||
}
|
||
// Now try to read the directory again after creating it
|
||
files, err = os.ReadDir(cfg.RAGDir)
|
||
if err != nil {
|
||
logger.Error("failed to read dir after creating it", "dir", cfg.RAGDir, "error", err)
|
||
showToast("failed to read RAG directory", err.Error())
|
||
return nil
|
||
}
|
||
} else {
|
||
// Other error (permissions, etc.)
|
||
logger.Error("failed to read dir", "dir", cfg.RAGDir, "error", err)
|
||
showToast("failed to open RAG files dir", err.Error())
|
||
return nil
|
||
}
|
||
}
|
||
// Get files from ragdir
|
||
fileList := []string{}
|
||
for _, f := range files {
|
||
if f.IsDir() {
|
||
continue
|
||
}
|
||
fileList = append(fileList, f.Name())
|
||
}
|
||
// Get loaded files from vector DB
|
||
loadedFiles, err := ragger.ListLoaded()
|
||
if err != nil {
|
||
logger.Error("failed to list loaded RAG files", "error", err)
|
||
loadedFiles = []string{} // Continue with empty list on error
|
||
}
|
||
chatRAGTable := makeRAGTable(fileList, loadedFiles)
|
||
pages.AddPage(RAGPage, chatRAGTable, true, true)
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyRune && event.Modifiers() == tcell.ModAlt && event.Rune() == '1' {
|
||
// Toggle shell mode: when enabled, commands are executed locally instead of sent to LLM
|
||
toggleShellMode()
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyRune && event.Modifiers() == tcell.ModAlt && event.Rune() == '9' {
|
||
// Warm up (load) the currently selected model
|
||
go warmUpModel()
|
||
showToast("model warmup", "loading model: "+chatBody.Model)
|
||
return nil
|
||
}
|
||
// cannot send msg in editMode or botRespMode
|
||
if event.Key() == tcell.KeyEscape && !editMode && !botRespMode {
|
||
if shellMode {
|
||
cmdText := shellInput.GetText()
|
||
if cmdText != "" {
|
||
executeCommandAndDisplay(cmdText)
|
||
shellInput.SetText("")
|
||
}
|
||
return nil
|
||
}
|
||
msgText := textArea.GetText()
|
||
nl := "\n\n" // keep empty lines between messages
|
||
prevText := textView.GetText(true)
|
||
persona := cfg.UserRole
|
||
// strings.LastIndex()
|
||
// newline is not needed is prev msg ends with one
|
||
if strings.HasSuffix(prevText, nl) {
|
||
nl = ""
|
||
} else if strings.HasSuffix(prevText, "\n") {
|
||
nl = "\n" // only one newline, add another
|
||
}
|
||
if msgText != "" {
|
||
// as what char user sends msg?
|
||
if cfg.WriteNextMsgAs != "" {
|
||
persona = cfg.WriteNextMsgAs
|
||
}
|
||
// check if plain text
|
||
if !injectRole {
|
||
matches := roleRE.FindStringSubmatch(msgText)
|
||
if len(matches) > 1 {
|
||
persona = matches[1]
|
||
msgText = strings.TrimLeft(msgText[len(matches[0]):], " ")
|
||
}
|
||
}
|
||
// add user icon before user msg
|
||
fmt.Fprintf(textView, "%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n",
|
||
nl, len(chatBody.Messages), persona, msgText)
|
||
textArea.SetText("", true)
|
||
if scrollToEndEnabled {
|
||
textView.ScrollToEnd()
|
||
}
|
||
colorText()
|
||
} else {
|
||
pages.AddPage(confirmPageName, confirmModal, true, true)
|
||
return nil
|
||
}
|
||
// go chatRound(msgText, persona, textView, false, false)
|
||
chatRoundChan <- &models.ChatRoundReq{Role: persona, UserMsg: msgText}
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyTab {
|
||
currentF := app.GetFocus()
|
||
if currentF == textArea {
|
||
currentText := textArea.GetText()
|
||
atIndex := strings.LastIndex(currentText, "@")
|
||
if atIndex >= 0 {
|
||
filter := currentText[atIndex+1:]
|
||
showTextAreaFileCompletionPopup(filter)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
if event.Key() == tcell.KeyPgUp || event.Key() == tcell.KeyPgDn {
|
||
currentF := app.GetFocus()
|
||
app.SetFocus(focusSwitcher[currentF])
|
||
return nil
|
||
}
|
||
if isASCII(string(event.Rune())) && !botRespMode {
|
||
return event
|
||
}
|
||
return event
|
||
})
|
||
}
|