2 Commits

Author SHA1 Message Date
Grail Finder
17f0afac80 Feat: scan files to complete [WIP] 2026-02-19 07:18:47 +03:00
Grail Finder
931b646c30 Enha: codingdir for coding assistant 2026-02-18 22:00:52 +03:00
7 changed files with 187 additions and 2 deletions

View File

@@ -43,6 +43,7 @@ STT_SR = 16000 # Sample rate for audio recording
DBPATH = "gflt.db" DBPATH = "gflt.db"
FilePickerDir = "." # Directory where file picker should start FilePickerDir = "." # Directory where file picker should start
FilePickerExts = "png,jpg,jpeg,gif,webp" # Comma-separated list of allowed file extensions for file picker FilePickerExts = "png,jpg,jpeg,gif,webp" # Comma-separated list of allowed file extensions for file picker
CodingDir = "." # Default directory for coding assistant file operations (relative paths resolved against this)
EnableMouse = false # Enable mouse support in the UI EnableMouse = false # Enable mouse support in the UI
# character specific context # character specific context
CharSpecificContextEnabled = true CharSpecificContextEnabled = true

View File

@@ -31,6 +31,7 @@ type Config struct {
DBPATH string `toml:"DBPATH"` DBPATH string `toml:"DBPATH"`
FilePickerDir string `toml:"FilePickerDir"` FilePickerDir string `toml:"FilePickerDir"`
FilePickerExts string `toml:"FilePickerExts"` FilePickerExts string `toml:"FilePickerExts"`
CodingDir string `toml:"CodingDir"`
ImagePreview bool `toml:"ImagePreview"` ImagePreview bool `toml:"ImagePreview"`
EnableMouse bool `toml:"EnableMouse"` EnableMouse bool `toml:"EnableMouse"`
// embeddings // embeddings

View File

@@ -145,6 +145,9 @@ This document explains how to set up and configure the application using the `co
#### FilePickerExts (`"png,jpg,jpeg,gif,webp"`) #### FilePickerExts (`"png,jpg,jpeg,gif,webp"`)
- Comma-separated list of allowed file extensions for the file picker. - Comma-separated list of allowed file extensions for the file picker.
#### CodingDir (`"."`)
- Default directory for coding assistant file operations. Relative paths in file tools (file_read, file_write, etc.) are resolved against this directory. Use absolute paths (starting with `/`) to bypass this.
#### EnableMouse (`false`) #### EnableMouse (`false`)
- Enable or disable mouse support in the UI. When set to `true`, allows clicking buttons and interacting with UI elements using the mouse, but prevents the terminal from handling mouse events normally (such as selecting and copying text). When set to `false`, enables default terminal behavior allowing you to select and copy text, but disables mouse interaction with UI elements. - Enable or disable mouse support in the UI. When set to `true`, allows clicking buttons and interacting with UI elements using the mouse, but prevents the terminal from handling mouse events normally (such as selecting and copying text). When set to `false`, enables default terminal behavior allowing you to select and copy text, but disables mouse interaction with UI elements.

File diff suppressed because one or more lines are too long

View File

@@ -820,7 +820,7 @@ func makeFilePicker() *tview.Flex {
} }
// Create UI elements // Create UI elements
listView := tview.NewList() listView := tview.NewList()
listView.SetBorder(true).SetTitle("Files & Directories").SetTitleAlign(tview.AlignLeft) listView.SetBorder(true).SetTitle("Files & Directories [c: set CodingDir]").SetTitleAlign(tview.AlignLeft)
// Status view for selected file information // Status view for selected file information
statusView := tview.NewTextView() statusView := tview.NewTextView()
statusView.SetBorder(true).SetTitle("Selected File").SetTitleAlign(tview.AlignLeft) statusView.SetBorder(true).SetTitle("Selected File").SetTitleAlign(tview.AlignLeft)
@@ -1032,6 +1032,37 @@ func makeFilePicker() *tview.Flex {
refreshList(currentDisplayDir, "") refreshList(currentDisplayDir, "")
return nil return nil
} }
if event.Rune() == 'c' {
// Set CodingDir to current directory
itemIndex := listView.GetCurrentItem()
if itemIndex >= 0 && itemIndex < listView.GetItemCount() {
itemText, _ := listView.GetItemText(itemIndex)
// Get the actual directory path
var targetDir string
if strings.HasPrefix(itemText, "Exit") || strings.HasPrefix(itemText, "Select this directory") {
targetDir = currentDisplayDir
} else {
actualItemName := itemText
if bracketPos := strings.Index(itemText, " ["); bracketPos != -1 {
actualItemName = itemText[:bracketPos]
}
if strings.HasPrefix(actualItemName, "../") {
targetDir = path.Dir(currentDisplayDir)
} else if strings.HasSuffix(actualItemName, "/") {
dirName := strings.TrimSuffix(actualItemName, "/")
targetDir = path.Join(currentDisplayDir, dirName)
} else {
targetDir = currentDisplayDir
}
}
cfg.CodingDir = targetDir
if err := notifyUser("CodingDir", "Set to: "+targetDir); err != nil {
logger.Error("failed to notify user", "error", err)
}
pages.RemovePage(filePickerPage)
return nil
}
}
case tcell.KeyEnter: case tcell.KeyEnter:
// Get the currently highlighted item in the list // Get the currently highlighted item in the list
itemIndex := listView.GetCurrentItem() itemIndex := listView.GetCurrentItem()

View File

@@ -9,6 +9,7 @@ import (
"io" "io"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@@ -377,6 +378,8 @@ func fileCreate(args map[string]string) []byte {
return []byte(msg) return []byte(msg)
} }
path = resolvePath(path)
content, ok := args["content"] content, ok := args["content"]
if !ok { if !ok {
content = "" content = ""
@@ -400,6 +403,8 @@ func fileRead(args map[string]string) []byte {
return []byte(msg) return []byte(msg)
} }
path = resolvePath(path)
content, err := readStringFromFile(path) content, err := readStringFromFile(path)
if err != nil { if err != nil {
msg := "failed to read file; error: " + err.Error() msg := "failed to read file; error: " + err.Error()
@@ -428,6 +433,7 @@ func fileWrite(args map[string]string) []byte {
logger.Error(msg) logger.Error(msg)
return []byte(msg) return []byte(msg)
} }
path = resolvePath(path)
content, ok := args["content"] content, ok := args["content"]
if !ok { if !ok {
content = "" content = ""
@@ -448,6 +454,7 @@ func fileWriteAppend(args map[string]string) []byte {
logger.Error(msg) logger.Error(msg)
return []byte(msg) return []byte(msg)
} }
path = resolvePath(path)
content, ok := args["content"] content, ok := args["content"]
if !ok { if !ok {
content = "" content = ""
@@ -469,6 +476,8 @@ func fileDelete(args map[string]string) []byte {
return []byte(msg) return []byte(msg)
} }
path = resolvePath(path)
if err := removeFile(path); err != nil { if err := removeFile(path); err != nil {
msg := "failed to delete file; error: " + err.Error() msg := "failed to delete file; error: " + err.Error()
logger.Error(msg) logger.Error(msg)
@@ -486,6 +495,7 @@ func fileMove(args map[string]string) []byte {
logger.Error(msg) logger.Error(msg)
return []byte(msg) return []byte(msg)
} }
src = resolvePath(src)
dst, ok := args["dst"] dst, ok := args["dst"]
if !ok || dst == "" { if !ok || dst == "" {
@@ -493,6 +503,7 @@ func fileMove(args map[string]string) []byte {
logger.Error(msg) logger.Error(msg)
return []byte(msg) return []byte(msg)
} }
dst = resolvePath(dst)
if err := moveFile(src, dst); err != nil { if err := moveFile(src, dst); err != nil {
msg := "failed to move file; error: " + err.Error() msg := "failed to move file; error: " + err.Error()
@@ -511,6 +522,7 @@ func fileCopy(args map[string]string) []byte {
logger.Error(msg) logger.Error(msg)
return []byte(msg) return []byte(msg)
} }
src = resolvePath(src)
dst, ok := args["dst"] dst, ok := args["dst"]
if !ok || dst == "" { if !ok || dst == "" {
@@ -518,6 +530,7 @@ func fileCopy(args map[string]string) []byte {
logger.Error(msg) logger.Error(msg)
return []byte(msg) return []byte(msg)
} }
dst = resolvePath(dst)
if err := copyFile(src, dst); err != nil { if err := copyFile(src, dst); err != nil {
msg := "failed to copy file; error: " + err.Error() msg := "failed to copy file; error: " + err.Error()
@@ -535,6 +548,8 @@ func fileList(args map[string]string) []byte {
path = "." // default to current directory path = "." // default to current directory
} }
path = resolvePath(path)
files, err := listDirectory(path) files, err := listDirectory(path)
if err != nil { if err != nil {
msg := "failed to list directory; error: " + err.Error() msg := "failed to list directory; error: " + err.Error()
@@ -558,6 +573,13 @@ func fileList(args map[string]string) []byte {
// Helper functions for file operations // Helper functions for file operations
func resolvePath(p string) string {
if filepath.IsAbs(p) {
return p
}
return filepath.Join(cfg.CodingDir, p)
}
func readStringFromFile(filename string) (string, error) { func readStringFromFile(filename string) (string, error) {
data, err := os.ReadFile(filename) data, err := os.ReadFile(filename)
if err != nil { if err != nil {

127
tui.go
View File

@@ -51,6 +51,13 @@ var (
filePickerPage = "filePicker" filePickerPage = "filePicker"
exportDir = "chat_exports" exportDir = "chat_exports"
// file completion
complPopup *tview.List
complActive bool
complAtPos int
complMatches []string
complIndex int
// For overlay search functionality // For overlay search functionality
searchField *tview.InputField searchField *tview.InputField
searchPageName = "searchOverlay" searchPageName = "searchOverlay"
@@ -76,6 +83,8 @@ var (
[yellow]Ctrl+c[white]: close programm [yellow]Ctrl+c[white]: close programm
[yellow]Ctrl+n[white]: start a new chat [yellow]Ctrl+n[white]: start a new chat
[yellow]Ctrl+o[white]: open image file picker [yellow]Ctrl+o[white]: open image file picker
[yellow]@[white]: file completion (type @ in input to get file suggestions)
[yellow]c[white]: (in file picker) set current dir as CodingDir
[yellow]Ctrl+p[white]: props edit form (min-p, dry, etc.) [yellow]Ctrl+p[white]: props edit form (min-p, dry, etc.)
[yellow]Ctrl+v[white]: show API link selection popup to choose current API [yellow]Ctrl+v[white]: show API link selection popup to choose current API
[yellow]Ctrl+r[white]: start/stop recording from your microphone (needs stt server or whisper binary) [yellow]Ctrl+r[white]: start/stop recording from your microphone (needs stt server or whisper binary)
@@ -492,6 +501,74 @@ func searchPrev() {
highlightCurrentMatch() 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() { func init() {
tview.Styles = colorschemes["default"] tview.Styles = colorschemes["default"]
app = tview.NewApplication() app = tview.NewApplication()
@@ -499,6 +576,56 @@ func init() {
textArea = tview.NewTextArea(). textArea = tview.NewTextArea().
SetPlaceholder("input is multiline; press <Enter> to start the next line;\npress <Esc> to send the message.") SetPlaceholder("input is multiline; press <Enter> to start the next line;\npress <Esc> to send the message.")
textArea.SetBorder(true).SetTitle("input") textArea.SetBorder(true).SetTitle("input")
// Setup file completion popup
complPopup = tview.NewList()
complPopup.SetBorder(true).SetTitle("Files (@ to trigger)")
pages.AddPage("complPopup", complPopup, false, false)
// Add input capture for @ completion
textArea.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyRune && event.Rune() == '@' {
complAtPos = len(textArea.GetText())
showFileCompletion("")
return event
}
if complActive {
switch event.Key() {
case tcell.KeyUp:
if complIndex > 0 {
complIndex--
complPopup.SetCurrentItem(complIndex)
}
return nil
case tcell.KeyDown:
if complIndex < len(complMatches)-1 {
complIndex++
complPopup.SetCurrentItem(complIndex)
}
return nil
case tcell.KeyTab, tcell.KeyEnter:
if len(complMatches) > 0 {
insertCompletion()
}
return nil
case tcell.KeyEsc:
hideCompletion()
return nil
}
}
if complActive && event.Key() == tcell.KeyRune {
r := event.Rune()
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '/' || r == '.' {
currentText := textArea.GetText()
if len(currentText) > complAtPos {
filter := currentText[complAtPos+1:]
showFileCompletion(filter)
}
}
}
return event
})
textView = tview.NewTextView(). textView = tview.NewTextView().
SetDynamicColors(true). SetDynamicColors(true).
SetRegions(true). SetRegions(true).