Chore: move helpers to helpfuncs
This commit is contained in:
405
helpfuncs.go
405
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user