diff options
| author | Grail Finder <wohilas@gmail.com> | 2026-02-19 08:13:41 +0300 |
|---|---|---|
| committer | Grail Finder <wohilas@gmail.com> | 2026-02-19 08:13:41 +0300 |
| commit | b861b92e5d63dcfc3b06030006668fac1247e5a8 (patch) | |
| tree | 4064a0f594befad25a98f9c52d73d33ef2580988 /helpfuncs.go | |
| parent | 17f0afac8021c4e7eb3a4da38cf5ec189c7b852f (diff) | |
Chore: move helpers to helpfuncs
Diffstat (limited to 'helpfuncs.go')
| -rw-r--r-- | helpfuncs.go | 405 |
1 files changed, 405 insertions, 0 deletions
diff --git a/helpfuncs.go b/helpfuncs.go index 1c4aa2e..f5e64ae 100644 --- a/helpfuncs.go +++ b/helpfuncs.go @@ -7,12 +7,15 @@ import ( "image" "net/url" "os" + "os/exec" "path" "slices" "strings" "unicode" "math/rand/v2" + + "github.com/rivo/tview" ) func isASCII(s string) bool { @@ -374,3 +377,405 @@ func deepseekModelValidator() error { } return nil } + +// == shellmode == + +func toggleShellMode() { + shellMode = !shellMode + if shellMode { + // Update input placeholder to indicate shell mode + textArea.SetPlaceholder("SHELL MODE: Enter command and press <Esc> to execute") + } else { + // Reset to normal mode + textArea.SetPlaceholder("input is multiline; press <Enter> to start the next line;\npress <Esc> 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_<position>_<index> + 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 +} + +func showFileCompletion(filter string) { + baseDir := cfg.CodingDir + if baseDir == "" { + baseDir = "." + } + complMatches = scanFiles(baseDir, filter) + go func() { + app.QueueUpdateDraw(func() { + if len(complMatches) == 0 { + hideCompletion() // Make sure hideCompletion also uses QueueUpdate if it modifies UI + return + } + // Limit the number of complMatches + if len(complMatches) > 10 { + complMatches = complMatches[:10] + } + // Update the popup's content + complPopup.Clear() + for _, f := range complMatches { + complPopup.AddItem(f, "", 0, nil) + } + // Update state and UI + complPopup.SetCurrentItem(0) + complActive = true + // Show the popup and set focus + pages.AddPage("complPopup", complPopup, true, true) + app.SetFocus(complPopup) + // No need to call app.Draw() here, QueueUpdateDraw does it automatically + }) + }() +} + +func insertCompletion() { + if complIndex >= 0 && complIndex < len(complMatches) { + match := complMatches[complIndex] + currentText := textArea.GetText() + atIdx := strings.LastIndex(currentText, "@") + if atIdx >= 0 { + before := currentText[:atIdx] + textArea.SetText(before+match, true) + } + } + hideCompletion() +} + +func hideCompletion() { + complActive = false + complMatches = nil + pages.RemovePage("complPopup") + app.SetFocus(textArea) + app.Draw() +} |
