From b861b92e5d63dcfc3b06030006668fac1247e5a8 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Thu, 19 Feb 2026 08:13:41 +0300 Subject: Chore: move helpers to helpfuncs --- helpfuncs.go | 405 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tui.go | 396 +-------------------------------------------------------- 2 files changed, 406 insertions(+), 395 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 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 +} + +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() +} diff --git a/tui.go b/tui.go index 07d211c..275aab7 100644 --- a/tui.go +++ b/tui.go @@ -7,7 +7,6 @@ import ( _ "image/jpeg" _ "image/png" "os" - "os/exec" "path" "strconv" "strings" @@ -179,396 +178,6 @@ Press or 'x' to return } ) -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 -} - -// 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() -} - -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) - if len(complMatches) == 0 { - hideCompletion() - return - } - if len(complMatches) > 10 { - complMatches = complMatches[:10] - } - complPopup.Clear() - for _, f := range complMatches { - complPopup.AddItem(f, "", 0, nil) - } - complIndex = 0 - complPopup.SetCurrentItem(0) - complActive = true - pages.AddPage("complPopup", complPopup, true, false) - app.SetFocus(complPopup) - app.Draw() -} - -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() -} - func init() { tview.Styles = colorschemes["default"] app = tview.NewApplication() @@ -584,7 +193,7 @@ func init() { // Add input capture for @ completion textArea.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyRune && event.Rune() == '@' { + if shellMode && event.Key() == tcell.KeyRune && event.Rune() == '@' { complAtPos = len(textArea.GetText()) showFileCompletion("") return event @@ -625,7 +234,6 @@ func init() { } return event }) - textView = tview.NewTextView(). SetDynamicColors(true). SetRegions(true). @@ -739,7 +347,6 @@ func init() { // colorText() // updateStatusLine() }) - roleEditWindow = tview.NewInputField(). SetLabel("Enter new role: "). SetPlaceholder("e.g., user, assistant, system, tool"). @@ -1433,7 +1040,6 @@ func init() { app.SetFocus(focusSwitcher[currentF]) return nil } - if isASCII(string(event.Rune())) && !botRespMode { return event } -- cgit v1.2.3