diff options
| -rw-r--r-- | tui.go | 427 |
1 files changed, 423 insertions, 4 deletions
@@ -31,6 +31,7 @@ var ( defaultImage = "sysprompts/llama.png" indexPickWindow *tview.InputField renameWindow *tview.InputField + searchWindow *tview.InputField fullscreenMode bool // pages historyPage = "historyPage" @@ -46,6 +47,11 @@ var ( imgPage = "imgPage" filePickerPage = "filePicker" exportDir = "chat_exports" + + // For overlay search functionality + searchField *tview.InputField + isSearching bool + searchPageName = "searchOverlay" // help text helpText = ` [yellow]Esc[white]: send msg @@ -86,6 +92,9 @@ var ( === scrolling chat window (some keys similar to vim) === [yellow]arrows up/down and j/k[white]: scroll up and down [yellow]gg/G[white]: jump to the begging / end of the chat +[yellow]/[white]: start searching for text +[yellow]n[white]: go to next search result +[yellow]N[white]: go to previous search result === status line === %s @@ -307,6 +316,346 @@ func parseCommand(cmd string) []string { 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 + +// stripTags creates a plain text version of a tview formatted string and a mapping +// from plain text indices to formatted text indices. +func stripTags(formatted string) (string, []int) { + var plain strings.Builder + // The mapping will store the byte index in the formatted string for each byte in the plain string. + mapping := make([]int, 0, len(formatted)) + + i := 0 + for i < len(formatted) { + if formatted[i] != '[' { + mapping = append(mapping, i) + plain.WriteByte(formatted[i]) + i++ + continue + } + + // We are at a '[' + if i+1 < len(formatted) && formatted[i+1] == '[' { // Escaped '[[' + mapping = append(mapping, i) + plain.WriteByte('[') + i += 2 + continue + } + + // It's a tag. Find its end. + end := -1 + // Region tags are of the form ["..."] + if i+1 < len(formatted) && formatted[i+1] == '"' { + // Find `"]` + for j := i + 2; j < len(formatted)-1; j++ { + if formatted[j] == '"' && formatted[j+1] == ']' { + end = j + 1 + break + } + } + } else { + // Color/attr tag [...] + closeBracket := strings.IndexRune(formatted[i:], ']') + if closeBracket != -1 { + end = i + closeBracket + } + } + + if end == -1 { + // Unterminated tag. Treat as literal. + mapping = append(mapping, i) + plain.WriteByte(formatted[i]) + i++ + } else { + // Skip tag + i = end + 1 + } + } + return plain.String(), mapping +} + +// 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 + // Re-render text without highlights + textView.SetText(chatToText(cfg.ShowSys)) + colorText() + return + } + + // Get formatted text + formattedText := textView.GetText(true) + plainText, mapping := stripTags(formattedText) + + // Find all occurrences of the search term in plain text + plainSearchResults := []int{} + + start := 0 + for { + // Use case-insensitive search + index := strings.Index(strings.ToLower(plainText[start:]), strings.ToLower(searchText)) + if index == -1 { + break + } + + absoluteIndex := start + index + plainSearchResults = append(plainSearchResults, absoluteIndex) + start = absoluteIndex + len(searchText) // Advance past the last match + } + + if len(plainSearchResults) > 0 { + searchResults = make([]int, len(plainSearchResults)) + searchResultLengths = make([]int, len(plainSearchResults)) + + for i, p_start := range plainSearchResults { + p_end_exclusive := p_start + len(searchText) + + f_start := mapping[p_start] + var f_end_exclusive int + if p_end_exclusive < len(mapping) { + f_end_exclusive = mapping[p_end_exclusive] + } else { + // Reached the end of the text + f_end_exclusive = len(formattedText) + } + + searchResults[i] = f_start + searchResultLengths[i] = f_end_exclusive - f_start + } + + searchIndex = 0 + highlightCurrentMatch() + } else { + // No matches found + searchResults = nil + searchResultLengths = nil + notification := fmt.Sprintf("Pattern not found: %s", term) + if err := notifyUser("search", notification); err != nil { + logger.Error("failed to send notification", "error", err) + } + } +} + +// 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 := textView.GetText(true) + + // 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) + } +} + +// applyAllHighlights applies highlighting to all search matches in the text +func applyAllHighlights(text string, positions []int, currentIdx int, searchTerm string) string { + if len(positions) == 0 { + return text + } + + // For performance and to avoid freezing, use a simpler approach just highlighting the positions + // that were found in the initial search (even if not perfectly mapped to formatted text) + var result strings.Builder + + // For simplicity and to prevent freezing, don't do complex recalculations + // Instead, we'll just highlight based on the initial search results + lastEnd := 0 + + // Since positions come from plain text search, they may not align with formatted text + // For robustness, only process positions that are within bounds + for i, pos := range positions { + // Only process if within text bounds + if pos >= len(text) { + continue + } + + endPos := pos + len(searchTerm) + if endPos > len(text) { + continue + } + + // Check if the actual text matches the search term (case insensitive) + actualText := text[pos:endPos] + if strings.ToLower(actualText) != strings.ToLower(searchTerm) { + continue // Skip if the text doesn't actually match at this position + } + + // Add text before this match + if pos > lastEnd { + result.WriteString(text[lastEnd:pos]) + } + + // Highlight this match + highlight := `[gold:red:u]` // All matches - gold on red + if i == currentIdx { + highlight = `[yellow:blue:b]` // Current match - yellow on blue bold + } + result.WriteString(highlight) + result.WriteString(actualText) + result.WriteString(`[-:-:-]`) // Reset formatting + + lastEnd = endPos + } + + // Add the rest of the text after the last processed match + if lastEnd < len(text) { + result.WriteString(text[lastEnd:]) + } + + return result.String() +} + +// showSearchBar shows the search input field as an overlay +func showSearchBar() { + isSearching = true + // 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() { + isSearching = false + pages.RemovePage(searchPageName) + // Return focus to the text view + app.SetFocus(textView) + // Clear the search field + searchField.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() +} + +// insertHighlightAtPosition inserts highlight tags around a specific position in the text +func insertHighlightAtPosition(originalText string, pos int, length int) string { + if pos < 0 || pos >= len(originalText) || pos+length > len(originalText) { + return originalText + } + + // Insert highlight tags around the match + var result strings.Builder + result.WriteString(originalText[:pos]) + result.WriteString(`[gold:red:u]`) // Highlight with gold text on red background and underline + result.WriteString(originalText[pos : pos+length]) + result.WriteString(`[-]`) // Reset to default formatting + result.WriteString(originalText[pos+length:]) + + return result.String() +} + +// highlightTextWithRegions adds region tags to highlight search matches +func highlightTextWithRegions(originalText string, matchStart int, matchLength int) string { + // For now, we'll return the original text and use tview's highlight system differently + // The highlighting will be applied via the textView.Highlight() method + return originalText +} + +// 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 init() { tview.Styles = colorschemes["default"] app = tview.NewApplication() @@ -320,17 +669,66 @@ func init() { SetChangedFunc(func() { app.Draw() }) + + flex = tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(textView, 0, 40, false). + AddItem(textArea, 0, 10, true). // Restore original height + AddItem(position, 0, 2, false) // textView.SetBorder(true).SetTitle("chat") textView.SetDoneFunc(func(key tcell.Key) { - currentSelection := textView.GetHighlights() if key == tcell.KeyEnter { - if len(currentSelection) > 0 { - textView.Highlight() + if len(searchResults) > 0 { // Check if a search is active + hideSearchBar() // Hide the search bar if visible + searchResults = nil // Clear search results + searchResultLengths = nil // Clear search result lengths + textView.SetText(chatToText(cfg.ShowSys)) // Reset text without search regions + colorText() // Apply normal chat coloring } else { - textView.Highlight("0").ScrollToHighlight() + // Original logic if no search is active + currentSelection := textView.GetHighlights() + if len(currentSelection) > 0 { + textView.Highlight() + } else { + textView.Highlight("0").ScrollToHighlight() + } } } }) + textView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + // Handle vim-like navigation in TextView + switch event.Key() { + case tcell.KeyRune: + switch event.Rune() { + case 'j': + // For line down + return event + case 'k': + // For line up + return event + case 'g': + // Go to beginning + textView.ScrollToBeginning() + return nil + case 'G': + // Go to end + textView.ScrollToEnd() + return nil + case '/': + // Search functionality - show search bar + showSearchBar() + return nil + case 'n': + // Next search result + searchNext() + return nil + case 'N': + // Previous search result + searchPrev() + return nil + } + } + return event + }) focusSwitcher[textArea] = textView focusSwitcher[textView] = textArea position = tview.NewTextView(). @@ -339,6 +737,7 @@ func init() { position.SetChangedFunc(func() { app.Draw() }) + // Initially set up flex without search bar flex = tview.NewFlex().SetDirection(tview.FlexRow). AddItem(textView, 0, 40, false). AddItem(textArea, 0, 10, true). // Restore original height @@ -460,6 +859,24 @@ func init() { return event }) // + searchField = tview.NewInputField(). + SetPlaceholder("Search... (Enter: search, Esc: cancel)"). + SetDoneFunc(func(key tcell.Key) { + if key == tcell.KeyEnter { + term := searchField.GetText() + if term != "" { + performSearch(term) + // Keep focus on textView after search + app.SetFocus(textView) + } + hideSearchBar() + } else if key == tcell.KeyEscape { + hideSearchBar() + } + }) + searchField.SetBorder(true).SetTitle("Search") + // Note: Initially hide the search field (handled by not showing it in the layout) + // helpView = tview.NewTextView().SetDynamicColors(true). SetText(fmt.Sprintf(helpText, makeStatusLine())). SetDoneFunc(func(key tcell.Key) { @@ -986,6 +1403,8 @@ func init() { app.SetFocus(focusSwitcher[currentF]) return nil } + + if isASCII(string(event.Rune())) && !botRespMode { return event } |
