From c83779b4796bdb3e42b43cdeffa1f4dba2014de3 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Mon, 16 Feb 2026 19:43:14 +0300 Subject: Doc: add attempts doc --- docs/filepicker-search.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/filepicker-search.md diff --git a/docs/filepicker-search.md b/docs/filepicker-search.md new file mode 100644 index 0000000..e595c7a --- /dev/null +++ b/docs/filepicker-search.md @@ -0,0 +1,66 @@ +# Filepicker Search Implementation - Notes + +## Goal +Add `/` key functionality in filepicker (Ctrl+O) to filter/search files by name, similar to how `/` works in the main TUI textview. + +## Requirements +- Press `/` to activate search mode +- Live case-insensitive filtering +- `../` (parent directory) always visible +- Show "No matching files" when nothing matches +- Esc to cancel (return to main app for sending messages) +- Enter to confirm search and close search input + +## Approaches Tried + +### Approach 1: Modify Flex Layout In-Place +Add search input to the existing flex container by replacing listView with searchInput. + +**Issues:** +- tview's `RemoveItem`/`AddItem` causes UI freezes/hangs +- Using `app.QueueUpdate` or `app.Draw` didn't help +- Layout changes don't render properly + +### Approach 2: Add Input Capture to ListView +Handle `/` key in listView's SetInputCapture. + +**Issues:** +- Key events don't reach listView when filepicker is open +- Global app input capture handles `/` for main textview search first +- Even when checking `pages.GetFrontPage()`, the key isn't captured + +### Approach 3: Global Handler with Page Replacement +Handle `/` in global app input capture when filepicker page is frontmost. + +**Issues:** +- Search input appears but text is invisible (color issues) +- Enter/Esc not handled - main TUI captures them +- Creating new pages adds on top instead of replacing, causing split-screen effect +- Enter on file item opens new filepicker (page stacking issue) + +### Approach 4: Overlay Page (Modal-style) +Create a new flex with search input on top and filepicker below, replace the page. + +**Issues:** +- Page replacement causes split-screen between main app and filepicker +- Search input renders but invisible text +- Enter/Esc handled by main TUI, not search input +- State lost when recreating filepicker + +## Root Causes + +1. **tview UI update issues**: Direct manipulation of flex items causes freezes or doesn't render +2. **Input capture priority**: Even with page overlay, main TUI's global input capture processes keys first +3. **Esc key conflict**: Esc is used for sending messages in main TUI, and it's hard to distinguish when filepicker is open +4. **Focus management**: tview's focus system doesn't work as expected with dynamic layouts + +## Possible Solutions (Not Tried) + +1. **Use tview's built-in Filter method**: ListView has a SetFilterFunc that might work +2. **Create separate search primitive**: Instead of replacing list, use a separate text input overlay +3. **Different key for search**: Use a key that isn't already mapped in main TUI +4. **Fork/extend tview**: May need to modify tview itself for better dynamic UI updates +5. **Use form with text input**: tview.Forms might handle input better + +## Current State +All search-related changes rolled back. Filepicker works as before without search functionality. -- cgit v1.2.3 From 475936fb1b2200c600869ba71c45057a6424252d Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Tue, 17 Feb 2026 11:16:52 +0300 Subject: Feat: filepicker search --- docs/filepicker-search.md | 66 ---------------- tables.go | 186 +++++++++++++++++++++++++++++----------------- 2 files changed, 116 insertions(+), 136 deletions(-) delete mode 100644 docs/filepicker-search.md diff --git a/docs/filepicker-search.md b/docs/filepicker-search.md deleted file mode 100644 index e595c7a..0000000 --- a/docs/filepicker-search.md +++ /dev/null @@ -1,66 +0,0 @@ -# Filepicker Search Implementation - Notes - -## Goal -Add `/` key functionality in filepicker (Ctrl+O) to filter/search files by name, similar to how `/` works in the main TUI textview. - -## Requirements -- Press `/` to activate search mode -- Live case-insensitive filtering -- `../` (parent directory) always visible -- Show "No matching files" when nothing matches -- Esc to cancel (return to main app for sending messages) -- Enter to confirm search and close search input - -## Approaches Tried - -### Approach 1: Modify Flex Layout In-Place -Add search input to the existing flex container by replacing listView with searchInput. - -**Issues:** -- tview's `RemoveItem`/`AddItem` causes UI freezes/hangs -- Using `app.QueueUpdate` or `app.Draw` didn't help -- Layout changes don't render properly - -### Approach 2: Add Input Capture to ListView -Handle `/` key in listView's SetInputCapture. - -**Issues:** -- Key events don't reach listView when filepicker is open -- Global app input capture handles `/` for main textview search first -- Even when checking `pages.GetFrontPage()`, the key isn't captured - -### Approach 3: Global Handler with Page Replacement -Handle `/` in global app input capture when filepicker page is frontmost. - -**Issues:** -- Search input appears but text is invisible (color issues) -- Enter/Esc not handled - main TUI captures them -- Creating new pages adds on top instead of replacing, causing split-screen effect -- Enter on file item opens new filepicker (page stacking issue) - -### Approach 4: Overlay Page (Modal-style) -Create a new flex with search input on top and filepicker below, replace the page. - -**Issues:** -- Page replacement causes split-screen between main app and filepicker -- Search input renders but invisible text -- Enter/Esc handled by main TUI, not search input -- State lost when recreating filepicker - -## Root Causes - -1. **tview UI update issues**: Direct manipulation of flex items causes freezes or doesn't render -2. **Input capture priority**: Even with page overlay, main TUI's global input capture processes keys first -3. **Esc key conflict**: Esc is used for sending messages in main TUI, and it's hard to distinguish when filepicker is open -4. **Focus management**: tview's focus system doesn't work as expected with dynamic layouts - -## Possible Solutions (Not Tried) - -1. **Use tview's built-in Filter method**: ListView has a SetFilterFunc that might work -2. **Create separate search primitive**: Instead of replacing list, use a separate text input overlay -3. **Different key for search**: Use a key that isn't already mapped in main TUI -4. **Fork/extend tview**: May need to modify tview itself for better dynamic UI updates -5. **Use form with text input**: tview.Forms might handle input better - -## Current State -All search-related changes rolled back. Filepicker works as before without search functionality. diff --git a/tables.go b/tables.go index e4066c8..239811c 100644 --- a/tables.go +++ b/tables.go @@ -789,17 +789,18 @@ func makeFilePicker() *tview.Flex { var selectedFile string // Track currently displayed directory (changes as user navigates) currentDisplayDir := startDir + // --- NEW: search state --- + searching := false + searchQuery := "" // Helper function to check if a file has an allowed extension from config hasAllowedExtension := func(filename string) bool { - // If no allowed extensions are specified in config, allow all files if cfg.FilePickerExts == "" { return true } - // Split the allowed extensions from the config string allowedExts := strings.Split(cfg.FilePickerExts, ",") lowerFilename := strings.ToLower(strings.TrimSpace(filename)) for _, ext := range allowedExts { - ext = strings.TrimSpace(ext) // Remove any whitespace around the extension + ext = strings.TrimSpace(ext) if ext != "" && strings.HasSuffix(lowerFilename, "."+ext) { return true } @@ -844,12 +845,12 @@ func makeFilePicker() *tview.Flex { flex := tview.NewFlex().SetDirection(tview.FlexRow) flex.AddItem(hFlex, 0, 3, true) flex.AddItem(statusView, 3, 0, false) - // Refresh the file list - var refreshList func(string) - refreshList = func(dir string) { + // Refresh the file list – now accepts a filter string + var refreshList func(string, string) + refreshList = func(dir string, filter string) { listView.Clear() // Update the current display directory - currentDisplayDir = dir // Update the current display directory + currentDisplayDir = dir // Add exit option at the top listView.AddItem("Exit file picker [gray](Close without selecting)[-]", "", 'x', func() { pages.RemovePage(filePickerPage) @@ -857,14 +858,16 @@ func makeFilePicker() *tview.Flex { // Add parent directory (..) if not at root if dir != "/" { parentDir := path.Dir(dir) - // Special handling for edge cases - only return if we're truly at a system root - // For Unix-like systems, path.Dir("/") returns "/" which would cause parentDir == dir - if parentDir == dir && dir == "/" { - // We're at the root ("/") and trying to go up, just don't add the parent item - } else { + // For Unix-like systems, avoid infinite loop when at root + if parentDir != dir { listView.AddItem("../ [gray](Parent Directory)[-]", "", 'p', func() { - imgPreview.SetImage(nil) - refreshList(parentDir) + // Clear search on navigation + searching = false + searchQuery = "" + if cfg.ImagePreview { + imgPreview.SetImage(nil) + } + refreshList(parentDir, "") dirStack = append(dirStack, parentDir) currentStackPos = len(dirStack) - 1 }) @@ -876,48 +879,66 @@ func makeFilePicker() *tview.Flex { statusView.SetText("Error reading directory: " + err.Error()) return } - // Add directories and files to the list + // Helper to check if an item passes the filter + matchesFilter := func(name string) bool { + if filter == "" { + return true + } + return strings.Contains(strings.ToLower(name), strings.ToLower(filter)) + } + // Add directories for _, file := range files { name := file.Name() - // Skip hidden files and directories (those starting with a dot) if strings.HasPrefix(name, ".") { continue } - if file.IsDir() { - // Capture the directory name for the closure to avoid loop variable issues + if file.IsDir() && matchesFilter(name) { dirName := name listView.AddItem(dirName+"/ [gray](Directory)[-]", "", 0, func() { - imgPreview.SetImage(nil) + // Clear search on navigation + searching = false + searchQuery = "" + if cfg.ImagePreview { + imgPreview.SetImage(nil) + } newDir := path.Join(dir, dirName) - refreshList(newDir) + refreshList(newDir, "") dirStack = append(dirStack, newDir) currentStackPos = len(dirStack) - 1 statusView.SetText("Current: " + newDir) }) - } else if hasAllowedExtension(name) { - // Only show files that have allowed extensions (from config) - // Capture the file name for the closure to avoid loop variable issues + } + } + // Add files with allowed extensions + for _, file := range files { + name := file.Name() + if strings.HasPrefix(name, ".") || file.IsDir() { + continue + } + if hasAllowedExtension(name) && matchesFilter(name) { fileName := name fullFilePath := path.Join(dir, fileName) listView.AddItem(fileName+" [gray](File)[-]", "", 0, func() { selectedFile = fullFilePath statusView.SetText("Selected: " + selectedFile) - // Check if the file is an image if isImageFile(fileName) { - // For image files, offer to attach to the next LLM message statusView.SetText("Selected image: " + selectedFile) - } else { - // For non-image files, display as before - statusView.SetText("Selected: " + selectedFile) } }) } } - statusView.SetText("Current: " + dir) + // Update status line based on search state + if searching { + statusView.SetText("Search: " + searchQuery + "_") + } else if searchQuery != "" { + statusView.SetText("Current: " + dir + " (filter: " + searchQuery + ")") + } else { + statusView.SetText("Current: " + dir) + } } // Initialize the file list - refreshList(startDir) - // Update image preview when selection changes + refreshList(startDir, "") + // Update image preview when selection changes (unchanged) if cfg.ImagePreview && imgPreview != nil { listView.SetChangedFunc(func(index int, mainText, secondaryText string, rune rune) { itemText, _ := listView.GetItemText(index) @@ -938,24 +959,51 @@ func makeFilePicker() *tview.Flex { return } filePath := path.Join(currentDisplayDir, actualItemName) - go func() { - file, err := os.Open(filePath) - if err != nil { - app.QueueUpdate(func() { imgPreview.SetImage(nil) }) - return - } - defer file.Close() - img, _, err := image.Decode(file) - if err != nil { - app.QueueUpdate(func() { imgPreview.SetImage(nil) }) - return - } - app.QueueUpdate(func() { imgPreview.SetImage(img) }) - }() + file, err := os.Open(filePath) + if err != nil { + imgPreview.SetImage(nil) + return + } + defer file.Close() + img, _, err := image.Decode(file) + if err != nil { + imgPreview.SetImage(nil) + return + } + imgPreview.SetImage(img) }) } // Set up keyboard navigation flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + // --- Handle search mode --- + if searching { + switch event.Key() { + case tcell.KeyEsc: + // Exit search, clear filter + searching = false + searchQuery = "" + refreshList(currentDisplayDir, "") + return nil + case tcell.KeyBackspace, tcell.KeyBackspace2: + if len(searchQuery) > 0 { + searchQuery = searchQuery[:len(searchQuery)-1] + refreshList(currentDisplayDir, searchQuery) + } + return nil + case tcell.KeyRune: + r := event.Rune() + if r != 0 { + searchQuery += string(r) + refreshList(currentDisplayDir, searchQuery) + } + return nil + default: + // Pass all other keys (arrows, Enter, etc.) to normal processing + // This allows selecting items while still in search mode + return event + } + } + // --- Not searching --- switch event.Key() { case tcell.KeyEsc: pages.RemovePage(filePickerPage) @@ -967,43 +1015,46 @@ func makeFilePicker() *tview.Flex { if currentStackPos > 0 { currentStackPos-- prevDir := dirStack[currentStackPos] - refreshList(prevDir) - // Trim the stack to current position to avoid deep history + // Clear search when navigating with backspace + searching = false + searchQuery = "" + refreshList(prevDir, "") + // Trim the stack to current position dirStack = dirStack[:currentStackPos+1] } return nil + case tcell.KeyRune: + if event.Rune() == '/' { + // Enter search mode + searching = true + searchQuery = "" + refreshList(currentDisplayDir, "") + return nil + } case tcell.KeyEnter: // Get the currently highlighted item in the list itemIndex := listView.GetCurrentItem() if itemIndex >= 0 && itemIndex < listView.GetItemCount() { - // We need to get the text of the currently selected item to determine if it's a directory - // Since we can't directly get the item text, we'll keep track of items differently - // Let's improve the approach by tracking the currently selected item itemText, _ := listView.GetItemText(itemIndex) logger.Info("choosing dir", "itemText", itemText) - // Check for the exit option first (should be the first item) + // Check for the exit option first if strings.HasPrefix(itemText, "Exit file picker") { pages.RemovePage(filePickerPage) return nil } - // Extract the actual filename/directory name by removing the type info in brackets - // Format is "name [gray](type)[-]" + // Extract the actual filename/directory name by removing the type info actualItemName := itemText if bracketPos := strings.Index(itemText, " ["); bracketPos != -1 { actualItemName = itemText[:bracketPos] } // Check if it's a directory (ends with /) if strings.HasSuffix(actualItemName, "/") { - // This is a directory, we need to get the full path - // Since the item text ends with "/" and represents a directory var targetDir string if strings.HasPrefix(actualItemName, "../") { - // Parent directory - need to go up from current directory + // Parent directory targetDir = path.Dir(currentDisplayDir) - // Avoid going above root - if parent is same as current and it's system root if targetDir == currentDisplayDir && currentDisplayDir == "/" { - // We're at root, don't navigate - logger.Warn("went to root", "dir", targetDir) + logger.Warn("at root, cannot go up") return nil } } else { @@ -1011,27 +1062,23 @@ func makeFilePicker() *tview.Flex { dirName := strings.TrimSuffix(actualItemName, "/") targetDir = path.Join(currentDisplayDir, dirName) } - // Navigate to the selected directory - logger.Info("going to the dir", "dir", targetDir) + // Navigate – clear search + logger.Info("going to dir", "dir", targetDir) if cfg.ImagePreview && imgPreview != nil { imgPreview.SetImage(nil) } - refreshList(targetDir) + searching = false + searchQuery = "" + refreshList(targetDir, "") dirStack = append(dirStack, targetDir) currentStackPos = len(dirStack) - 1 statusView.SetText("Current: " + targetDir) return nil } else { - // It's a file - construct the full path from current directory and the actual item name - // We can't rely only on the selectedFile variable since Enter key might be pressed - // without having clicked the file first + // It's a file filePath := path.Join(currentDisplayDir, actualItemName) - // Verify it's actually a file (not just lacking a directory suffix) if info, err := os.Stat(filePath); err == nil && !info.IsDir() { - // Check if the file is an image if isImageFile(actualItemName) { - // For image files, set it as an attachment for the next LLM message - // Use the version without UI updates to avoid hangs in event handlers logger.Info("setting image", "file", actualItemName) SetImageAttachment(filePath) logger.Info("after setting image", "file", actualItemName) @@ -1040,7 +1087,6 @@ func makeFilePicker() *tview.Flex { pages.RemovePage(filePickerPage) logger.Info("after update drawn", "file", actualItemName) } else { - // For non-image files, update the text area with file path textArea.SetText(filePath, true) app.SetFocus(textArea) pages.RemovePage(filePickerPage) -- cgit v1.2.3