Chore: move helpers to helpfuncs

This commit is contained in:
Grail Finder
2026-02-19 08:13:41 +03:00
parent 17f0afac80
commit b861b92e5d
2 changed files with 406 additions and 395 deletions

View File

@@ -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()
}