package main import ( "fmt" "gf-lt/models" "gf-lt/pngmeta" "image" "net/url" "os" "os/exec" "path" "slices" "strings" "unicode" "math/rand/v2" "github.com/rivo/tview" ) func isASCII(s string) bool { for i := 0; i < len(s); i++ { if s[i] > unicode.MaxASCII { return false } } return true } // stripThinkingFromMsg removes thinking blocks from assistant messages. // Skips user, tool, and system messages as they may contain thinking examples. func stripThinkingFromMsg(msg *models.RoleMsg) *models.RoleMsg { if !cfg.StripThinkingFromAPI { return msg } // Skip user, tool, and system messages - they might contain thinking examples if msg.Role == cfg.UserRole || msg.Role == cfg.ToolRole || msg.Role == "system" { return msg } // Strip thinking from assistant messages if thinkRE.MatchString(msg.Content) { msg.Content = thinkRE.ReplaceAllString(msg.Content, "") // Clean up any double newlines that might result msg.Content = strings.TrimSpace(msg.Content) } return msg } // refreshChatDisplay updates the chat display based on current character view // It filters messages for the character the user is currently "writing as" // and updates the textView with the filtered conversation func refreshChatDisplay() { // Determine which character's view to show viewingAs := cfg.UserRole if cfg.WriteNextMsgAs != "" { viewingAs = cfg.WriteNextMsgAs } // Filter messages for this character filteredMessages := filterMessagesForCharacter(chatBody.Messages, viewingAs) displayText := chatToText(filteredMessages, cfg.ShowSys) textView.SetText(displayText) colorText() if scrollToEndEnabled { textView.ScrollToEnd() } } func stopTTSIfNotForUser(msg *models.RoleMsg) { viewingAs := cfg.UserRole if cfg.WriteNextMsgAs != "" { viewingAs = cfg.WriteNextMsgAs } // stop tts if msg is not for user if cfg.CharSpecificContextEnabled && !slices.Contains(msg.KnownTo, viewingAs) && cfg.TTS_ENABLED { TTSDoneChan <- true } } func colorText() { text := textView.GetText(false) quoteReplacer := strings.NewReplacer( `”`, `"`, `“`, `"`, `“`, `"`, `”`, `"`, `**`, `*`, ) text = quoteReplacer.Replace(text) // Step 1: Extract code blocks and replace them with unique placeholders var codeBlocks []string placeholder := "__CODE_BLOCK_%d__" counter := 0 // thinking var thinkBlocks []string placeholderThink := "__THINK_BLOCK_%d__" counterThink := 0 // Replace code blocks with placeholders and store their styled versions text = codeBlockRE.ReplaceAllStringFunc(text, func(match string) string { // Style the code block and store it styled := fmt.Sprintf("[red::i]%s[-:-:-]", match) codeBlocks = append(codeBlocks, styled) // Generate a unique placeholder (e.g., "__CODE_BLOCK_0__") id := fmt.Sprintf(placeholder, counter) counter++ return id }) text = thinkRE.ReplaceAllStringFunc(text, func(match string) string { // Style the code block and store it styled := fmt.Sprintf("[red::i]%s[-:-:-]", match) thinkBlocks = append(thinkBlocks, styled) // Generate a unique placeholder (e.g., "__CODE_BLOCK_0__") id := fmt.Sprintf(placeholderThink, counterThink) counterThink++ return id }) // Step 2: Apply other regex styles to the non-code parts text = quotesRE.ReplaceAllString(text, `[orange::-]$1[-:-:-]`) text = starRE.ReplaceAllString(text, `[turquoise::i]$1[-:-:-]`) text = singleBacktickRE.ReplaceAllString(text, "`[pink::i]$1[-:-:-]`") // text = thinkRE.ReplaceAllString(text, `[yellow::i]$1[-:-:-]`) // Step 3: Restore the styled code blocks from placeholders for i, cb := range codeBlocks { text = strings.Replace(text, fmt.Sprintf(placeholder, i), cb, 1) } for i, tb := range thinkBlocks { text = strings.Replace(text, fmt.Sprintf(placeholderThink, i), tb, 1) } textView.SetText(text) } func updateStatusLine() { statusLineWidget.SetText(makeStatusLine()) helpView.SetText(fmt.Sprintf(helpText, makeStatusLine())) } func initSysCards() ([]string, error) { labels := []string{} labels = append(labels, sysLabels...) cards, err := pngmeta.ReadDirCards(cfg.SysDir, cfg.UserRole, logger) if err != nil { logger.Error("failed to read sys dir", "error", err) return nil, err } for _, cc := range cards { if cc.Role == "" { logger.Warn("empty role", "file", cc.FilePath) continue } sysMap[cc.Role] = cc labels = append(labels, cc.Role) } return labels, nil } func startNewChat(keepSysP bool) { id, err := store.ChatGetMaxID() if err != nil { logger.Error("failed to get chat id", "error", err) } if ok := charToStart(cfg.AssistantRole, keepSysP); !ok { logger.Warn("no such sys msg", "name", cfg.AssistantRole) } // set chat body chatBody.Messages = chatBody.Messages[:2] textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys)) newChat := &models.Chat{ ID: id + 1, Name: fmt.Sprintf("%d_%s", id+1, cfg.AssistantRole), // chat is written to db when we get first llm response (or any) // actual chat history (messages) would be parsed then Msgs: "", Agent: cfg.AssistantRole, } activeChatName = newChat.Name chatMap[newChat.Name] = newChat updateStatusLine() colorText() } func renameUser(oldname, newname string) { if oldname == "" { // not provided; deduce who user is // INFO: if user not yet spoke, it is hard to replace mentions in sysprompt and first message about thme roles := chatBody.ListRoles() for _, role := range roles { if role == cfg.AssistantRole { continue } if role == "tool" { continue } if role == "system" { continue } oldname = role break } if oldname == "" { // still logger.Warn("fn: renameUser; failed to find old name", "newname", newname) return } } viewText := textView.GetText(false) viewText = strings.ReplaceAll(viewText, oldname, newname) chatBody.Rename(oldname, newname) textView.SetText(viewText) } func setLogLevel(sl string) { switch sl { case "Debug": logLevel.Set(-4) case "Info": logLevel.Set(0) case "Warn": logLevel.Set(4) } } func listRolesWithUser() []string { roles := listChatRoles() // Remove user role if it exists in the list (to avoid duplicates and ensure it's at position 0) filteredRoles := make([]string, 0, len(roles)) for _, role := range roles { if role != cfg.UserRole { filteredRoles = append(filteredRoles, role) } } // Prepend user role to the beginning of the list result := append([]string{cfg.UserRole}, filteredRoles...) slices.Sort(result) return result } func loadImage() { filepath := defaultImage cc, ok := sysMap[cfg.AssistantRole] if ok { if strings.HasSuffix(cc.FilePath, ".png") { filepath = cc.FilePath } } file, err := os.Open(filepath) if err != nil { panic(err) } defer file.Close() img, _, err := image.Decode(file) if err != nil { panic(err) } imgView.SetImage(img) } func strInSlice(s string, sl []string) bool { for _, el := range sl { if strings.EqualFold(s, el) { return true } } return false } // isLocalLlamacpp checks if the current API is a local llama.cpp instance. func isLocalLlamacpp() bool { u, err := url.Parse(cfg.CurrentAPI) if err != nil { return false } host := u.Hostname() return host == "localhost" || host == "127.0.0.1" || host == "::1" } // getModelColor returns the color tag for the model name based on its load status. // For non-local models, returns orange. For local llama.cpp models, returns green if loaded, red if not. func getModelColor() string { if !isLocalLlamacpp() { return "orange" } // Check if model is loaded loaded, err := isModelLoaded(chatBody.Model) if err != nil { // On error, assume not loaded (red) return "red" } if loaded { return "green" } return "red" } func makeStatusLine() string { isRecording := false if asr != nil { isRecording = asr.IsRecording() } persona := cfg.UserRole if cfg.WriteNextMsgAs != "" { persona = cfg.WriteNextMsgAs } botPersona := cfg.AssistantRole if cfg.WriteNextMsgAsCompletionAgent != "" { botPersona = cfg.WriteNextMsgAsCompletionAgent } // Add image attachment info to status line var imageInfo string if imageAttachmentPath != "" { // Get just the filename from the path imageName := path.Base(imageAttachmentPath) imageInfo = fmt.Sprintf(" | attached img: [orange:-:b]%s[-:-:-]", imageName) } else { imageInfo = "" } // Add shell mode status to status line var shellModeInfo string if shellMode { shellModeInfo = " | [green:-:b]SHELL MODE[-:-:-]" } else { shellModeInfo = "" } // Get model color based on load status for local llama.cpp models modelColor := getModelColor() statusLine := fmt.Sprintf(indexLineCompletion, boolColors[botRespMode], botRespMode, activeChatName, boolColors[cfg.ToolUse], cfg.ToolUse, modelColor, chatBody.Model, boolColors[cfg.SkipLLMResp], cfg.SkipLLMResp, cfg.CurrentAPI, boolColors[isRecording], isRecording, persona, botPersona, boolColors[injectRole], injectRole) return statusLine + imageInfo + shellModeInfo } var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") func randString(n int) string { b := make([]rune, n) for i := range b { b[i] = letters[rand.IntN(len(letters))] } return string(b) } // set of roles within card definition and mention in chat history func listChatRoles() []string { currentChat, ok := chatMap[activeChatName] cbc := chatBody.ListRoles() if !ok { return cbc } currentCard, ok := sysMap[currentChat.Agent] if !ok { // case which won't let to switch roles: // started new chat (basic_sys or any other), at the start it yet be saved or have chatbody // if it does not have a card or chars, it'll return an empty slice // log error logger.Warn("failed to find current card in sysMap", "agent", currentChat.Agent, "sysMap", sysMap) return cbc } charset := []string{} for _, name := range currentCard.Characters { if !strInSlice(name, cbc) { charset = append(charset, name) } } charset = append(charset, cbc...) return charset } func deepseekModelValidator() error { if cfg.CurrentAPI == cfg.DeepSeekChatAPI || cfg.CurrentAPI == cfg.DeepSeekCompletionAPI { if chatBody.Model != "deepseek-chat" && chatBody.Model != "deepseek-reasoner" { if err := notifyUser("bad request", "wrong deepseek model name"); err != nil { logger.Warn("failed ot notify user", "error", err) return err } return nil } } return nil } // == shellmode == func toggleShellMode() { shellMode = !shellMode if shellMode { // Update input placeholder to indicate shell mode textArea.SetPlaceholder("SHELL MODE: Enter command and press to execute") } else { // Reset to normal mode textArea.SetPlaceholder("input is multiline; press to start the next line;\npress to send the message. Alt+1 to exit shell mode") } updateStatusLine() } func updateFlexLayout() { if fullscreenMode { // flex already contains only focused widget; do nothing return } flex.Clear() flex.AddItem(textView, 0, 40, false) flex.AddItem(textArea, 0, 10, false) if positionVisible { flex.AddItem(statusLineWidget, 0, 2, false) } // Keep focus on currently focused widget focused := app.GetFocus() if focused == textView { app.SetFocus(textView) } else { app.SetFocus(textArea) } } func executeCommandAndDisplay(cmdText string) { // Parse the command (split by spaces, but handle quoted arguments) cmdParts := parseCommand(cmdText) if len(cmdParts) == 0 { fmt.Fprintf(textView, "\n[red]Error: No command provided[-:-:-]\n") if scrollToEndEnabled { textView.ScrollToEnd() } colorText() return } command := cmdParts[0] args := []string{} if len(cmdParts) > 1 { args = cmdParts[1:] } // Create the command execution cmd := exec.Command(command, args...) // Execute the command and get output output, err := cmd.CombinedOutput() // Add the command being executed to the chat fmt.Fprintf(textView, "\n[yellow]$ %s[-:-:-]\n", cmdText) var outputContent string if err != nil { // Include both output and error errorMsg := "Error: " + err.Error() fmt.Fprintf(textView, "[red]%s[-:-:-]\n", errorMsg) if len(output) > 0 { outputStr := string(output) fmt.Fprintf(textView, "[red]%s[-:-:-]\n", outputStr) outputContent = errorMsg + "\n" + outputStr } else { outputContent = errorMsg } } else { // Only output if successful if len(output) > 0 { outputStr := string(output) fmt.Fprintf(textView, "[green]%s[-:-:-]\n", outputStr) outputContent = outputStr } else { successMsg := "Command executed successfully (no output)" fmt.Fprintf(textView, "[green]%s[-:-:-]\n", successMsg) outputContent = successMsg } } // Combine command and output in a single message for chat history combinedContent := "$ " + cmdText + "\n\n" + outputContent combinedMsg := models.RoleMsg{ Role: cfg.ToolRole, Content: combinedContent, } chatBody.Messages = append(chatBody.Messages, combinedMsg) // Scroll to end and update colors if scrollToEndEnabled { textView.ScrollToEnd() } colorText() } // parseCommand splits command string handling quotes properly func parseCommand(cmd string) []string { var args []string var current string var inQuotes bool var quoteChar rune for _, r := range cmd { switch r { case '"', '\'': if inQuotes { if r == quoteChar { inQuotes = false } else { current += string(r) } } else { inQuotes = true quoteChar = r } case ' ', '\t': if inQuotes { current += string(r) } else if current != "" { args = append(args, current) current = "" } default: current += string(r) } } if current != "" { args = append(args, current) } return args } // == search == // Global variables for search state var searchResults []int var searchResultLengths []int // To store the length of each match in the formatted string var searchIndex int var searchText string var originalTextForSearch string // performSearch searches for the given term in the textView content and highlights matches func performSearch(term string) { searchText = term if searchText == "" { searchResults = nil searchResultLengths = nil originalTextForSearch = "" // Re-render text without highlights textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys)) colorText() return } // Get formatted text and search directly in it to avoid mapping issues formattedText := textView.GetText(true) originalTextForSearch = formattedText searchTermLower := strings.ToLower(searchText) formattedTextLower := strings.ToLower(formattedText) // Find all occurrences of the search term in the formatted text directly formattedSearchResults := []int{} searchStart := 0 for { pos := strings.Index(formattedTextLower[searchStart:], searchTermLower) if pos == -1 { break } absolutePos := searchStart + pos formattedSearchResults = append(formattedSearchResults, absolutePos) searchStart = absolutePos + len(searchText) } if len(formattedSearchResults) == 0 { // No matches found searchResults = nil searchResultLengths = nil notification := "Pattern not found: " + term if err := notifyUser("search", notification); err != nil { logger.Error("failed to send notification", "error", err) } return } // Store the formatted text positions and lengths for accurate highlighting searchResults = formattedSearchResults // Create lengths array - all matches have the same length as the search term searchResultLengths = make([]int, len(formattedSearchResults)) for i := range searchResultLengths { searchResultLengths[i] = len(searchText) } searchIndex = 0 highlightCurrentMatch() } // highlightCurrentMatch highlights the current search match and scrolls to it func highlightCurrentMatch() { if len(searchResults) == 0 || searchIndex >= len(searchResults) { return } // Get the stored formatted text formattedText := originalTextForSearch // For tview to properly support highlighting and scrolling, we need to work with its region system // Instead of just applying highlights, we need to add region tags to the text highlightedText := addRegionTags(formattedText, searchResults, searchResultLengths, searchIndex, searchText) // Update the text view with the text that includes region tags textView.SetText(highlightedText) // Highlight the current region and scroll to it // Need to identify which position in the results array corresponds to the current match // The region ID will be search__ currentRegion := fmt.Sprintf("search_%d_%d", searchResults[searchIndex], searchIndex) textView.Highlight(currentRegion).ScrollToHighlight() // Send notification about which match we're at notification := fmt.Sprintf("Match %d of %d", searchIndex+1, len(searchResults)) if err := notifyUser("search", notification); err != nil { logger.Error("failed to send notification", "error", err) } } // showSearchBar shows the search input field as an overlay func showSearchBar() { // Create a temporary flex to combine search and main content updatedFlex := tview.NewFlex().SetDirection(tview.FlexRow). AddItem(searchField, 3, 0, true). // Search field at top AddItem(flex, 0, 1, false) // Main flex layout below // Add the search overlay as a page pages.AddPage(searchPageName, updatedFlex, true, true) app.SetFocus(searchField) } // hideSearchBar hides the search input field func hideSearchBar() { pages.RemovePage(searchPageName) // Return focus to the text view app.SetFocus(textView) // Clear the search field searchField.SetText("") } // Global variables for index overlay functionality var indexPageName = "indexOverlay" // showIndexBar shows the index input field as an overlay at the top func showIndexBar() { // Create a temporary flex to combine index input and main content updatedFlex := tview.NewFlex().SetDirection(tview.FlexRow). AddItem(indexPickWindow, 3, 0, true). // Index field at top AddItem(flex, 0, 1, false) // Main flex layout below // Add the index overlay as a page pages.AddPage(indexPageName, updatedFlex, true, true) app.SetFocus(indexPickWindow) } // hideIndexBar hides the index input field func hideIndexBar() { pages.RemovePage(indexPageName) // Return focus to the text view app.SetFocus(textView) // Clear the index field indexPickWindow.SetText("") } // addRegionTags adds region tags to search matches in the text for tview highlighting func addRegionTags(text string, positions []int, lengths []int, currentIdx int, searchTerm string) string { if len(positions) == 0 { return text } var result strings.Builder lastEnd := 0 for i, pos := range positions { endPos := pos + lengths[i] // Add text before this match if pos > lastEnd { result.WriteString(text[lastEnd:pos]) } // The matched text, which may contain its own formatting tags actualText := text[pos:endPos] // Add region tag and highlighting for this match // Use a unique region id that includes the match index to avoid conflicts regionId := fmt.Sprintf("search_%d_%d", pos, i) // position + index to ensure uniqueness var highlightStart, highlightEnd string if i == currentIdx { // Current match - use different highlighting highlightStart = fmt.Sprintf(`["%s"][yellow:blue:b]`, regionId) // Current match with region and special highlight highlightEnd = `[-:-:-][""]` // Reset formatting and close region } else { // Other matches - use regular highlighting highlightStart = fmt.Sprintf(`["%s"][gold:red:u]`, regionId) // Other matches with region and highlight highlightEnd = `[-:-:-][""]` // Reset formatting and close region } result.WriteString(highlightStart) result.WriteString(actualText) result.WriteString(highlightEnd) lastEnd = endPos } // Add the rest of the text after the last processed match if lastEnd < len(text) { result.WriteString(text[lastEnd:]) } return result.String() } // searchNext finds the next occurrence of the search term func searchNext() { if len(searchResults) == 0 { if err := notifyUser("search", "No search results to navigate"); err != nil { logger.Error("failed to send notification", "error", err) } return } searchIndex = (searchIndex + 1) % len(searchResults) highlightCurrentMatch() } // searchPrev finds the previous occurrence of the search term func searchPrev() { if len(searchResults) == 0 { if err := notifyUser("search", "No search results to navigate"); err != nil { logger.Error("failed to send notification", "error", err) } return } if searchIndex == 0 { searchIndex = len(searchResults) - 1 } else { searchIndex-- } highlightCurrentMatch() } // == tab completion == func scanFiles(dir, filter string) []string { var files []string entries, err := os.ReadDir(dir) if err != nil { return files } for _, entry := range entries { name := entry.Name() if strings.HasPrefix(name, ".") { continue } if filter == "" || strings.HasPrefix(strings.ToLower(name), strings.ToLower(filter)) { if entry.IsDir() { files = append(files, name+"/") } else { files = append(files, name) } } } return files }