diff options
| -rw-r--r-- | config.example.toml | 1 | ||||
| -rw-r--r-- | config/config.go | 2 | ||||
| -rw-r--r-- | tables.go | 172 | ||||
| -rw-r--r-- | tui.go | 10 |
4 files changed, 184 insertions, 1 deletions
diff --git a/config.example.toml b/config.example.toml index ca88e3b..a6d3290 100644 --- a/config.example.toml +++ b/config.example.toml @@ -27,6 +27,7 @@ WhisperModelPath = "./ggml-model.bin" # Path to whisper model file (for WHISPER STT_LANG = "en" # Language for speech recognition (for WHISPER_BINARY mode) STT_SR = 16000 # Sample rate for audio recording DBPATH = "gflt.db" +FilePickerDir = "." # Directory where file picker should start # FetchModelNameAPI = "http://localhost:8080/v1/models" # external search tool diff --git a/config/config.go b/config/config.go index 77873e8..ff74089 100644 --- a/config/config.go +++ b/config/config.go @@ -64,6 +64,7 @@ type Config struct { WhisperModelPath string `toml:"WhisperModelPath"` STT_LANG string `toml:"STT_LANG"` DBPATH string `toml:"DBPATH"` + FilePickerDir string `toml:"FilePickerDir"` } func LoadConfigOrDefault(fn string) *Config { @@ -99,6 +100,7 @@ func LoadConfigOrDefault(fn string) *Config { config.TTS_URL = "http://localhost:8880/v1/audio/speech" config.FetchModelNameAPI = "http://localhost:8080/v1/models" config.STT_SR = 16000 + config.FilePickerDir = "." // Default to current directory } config.CurrentAPI = config.ChatAPI config.APIMap = map[string]string{ @@ -537,3 +537,175 @@ func makeImportChatTable(filenames []string) *tview.Table { }) return chatActTable } + +func makeFilePicker() *tview.Flex { + // Initialize with directory from config or current directory + currentDir := cfg.FilePickerDir + if currentDir == "" { + currentDir = "." + } + + // Track navigation history + dirStack := []string{currentDir} + currentStackPos := 0 + + // Track selected file + var selectedFile string + + // Create UI elements + listView := tview.NewList() + listView.SetBorder(true).SetTitle("Files & Directories").SetTitleAlign(tview.AlignLeft) + + statusView := tview.NewTextView() + statusView.SetBorder(true).SetTitle("Selected File").SetTitleAlign(tview.AlignLeft) + statusView.SetTextColor(tcell.ColorYellow) + + buttonBar := tview.NewFlex() + + // Button functions + loadButton := tview.NewButton("Load") + loadButton.SetSelectedFunc(func() { + if selectedFile != "" { + // Update the global text area with the selected file path + textArea.SetText(selectedFile, true) + app.SetFocus(textArea) + } + pages.RemovePage(filePickerPage) + }) + + cancelButton := tview.NewButton("Cancel") + cancelButton.SetSelectedFunc(func() { + pages.RemovePage(filePickerPage) + }) + + buttonBar.AddItem(tview.NewBox().SetBackgroundColor(tcell.ColorDefault), 0, 1, false) + buttonBar.AddItem(loadButton, 8, 1, true) + buttonBar.AddItem(tview.NewBox(), 1, 1, false) + buttonBar.AddItem(cancelButton, 8, 1, true) + buttonBar.AddItem(tview.NewBox().SetBackgroundColor(tcell.ColorDefault), 0, 1, false) + + // Layout + flex := tview.NewFlex().SetDirection(tview.FlexRow) + flex.AddItem(listView, 0, 3, true) + flex.AddItem(statusView, 3, 0, false) + flex.AddItem(buttonBar, 3, 0, false) + + // Refresh the file list + var refreshList func(string) + refreshList = func(dir string) { + listView.Clear() + + // 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 { + listView.AddItem("../", "(Parent Directory)", 'p', func() { + refreshList(parentDir) + dirStack = append(dirStack, parentDir) + currentStackPos = len(dirStack) - 1 + }) + } + } + + // Read directory contents + files, err := os.ReadDir(dir) + if err != nil { + statusView.SetText("Error reading directory: " + err.Error()) + return + } + + // Add directories and files to the list + for _, file := range files { + name := file.Name() + if file.IsDir() { + listView.AddItem(name+"/", "(Directory)", 0, func() { + newDir := path.Join(dir, name) + refreshList(newDir) + dirStack = append(dirStack, newDir) + currentStackPos = len(dirStack) - 1 + statusView.SetText("Current: " + newDir) + }) + } else { + listView.AddItem(name, "(File)", 0, func() { + selectedFile = path.Join(dir, name) + statusView.SetText("Selected: " + selectedFile) + }) + } + } + + statusView.SetText("Current: " + dir) + } + + // Initialize the file list + refreshList(currentDir) + + // Set up keyboard navigation + flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEsc: + pages.RemovePage(filePickerPage) + return nil + case tcell.KeyBackspace2: // Backspace to go to parent directory + if currentStackPos > 0 { + currentStackPos-- + prevDir := dirStack[currentStackPos] + refreshList(prevDir) + // Trim the stack to current position to avoid deep history + dirStack = dirStack[:currentStackPos+1] + } + 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) + + // Check if it's a directory (typically ends with /) + if strings.HasSuffix(itemText, "/") { + // 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(itemText, "../") { + // Parent directory - need to go up from current directory + targetDir = path.Dir(currentDir) + // Avoid going above root - if parent is same as current and it's system root + if targetDir == currentDir && currentDir == "/" { + // We're at root, don't navigate + return nil + } + } else { + // Regular subdirectory + dirName := strings.TrimSuffix(itemText, "/") + targetDir = path.Join(currentDir, dirName) + } + + // Navigate to the selected directory + refreshList(targetDir) + dirStack = append(dirStack, targetDir) + currentStackPos = len(dirStack) - 1 + statusView.SetText("Current: " + targetDir) + return nil + } else { + // It's a file, load it if one was selected + if selectedFile != "" { + textArea.SetText(selectedFile, true) + app.SetFocus(textArea) + pages.RemovePage(filePickerPage) + } + return nil + } + } + return nil + } + return event + }) + + return flex +} @@ -42,6 +42,7 @@ var ( propsPage = "propsPage" codeBlockPage = "codeBlockPage" imgPage = "imgPage" + filePickerPage = "filePicker" exportDir = "chat_exports" // help text helpText = ` @@ -62,8 +63,9 @@ var ( [yellow]Ctrl+w[white]: resume generation on the last msg [yellow]Ctrl+s[white]: load new char/agent [yellow]Ctrl+e[white]: export chat to json file -[yellow]Ctrl+n[white]: start a new chat [yellow]Ctrl+c[white]: close programm +[yellow]Ctrl+n[white]: start a new chat +[yellow]Ctrl+o[white]: open file picker [yellow]Ctrl+p[white]: props edit form (min-p, dry, etc.) [yellow]Ctrl+v[white]: switch between /completion and /chat api (if provided in config) [yellow]Ctrl+r[white]: start/stop recording from your microphone (needs stt server) @@ -742,6 +744,12 @@ func init() { startNewChat() return nil } + if event.Key() == tcell.KeyCtrlO { + // open file picker + filePicker := makeFilePicker() + pages.AddPage(filePickerPage, filePicker, true, true) + return nil + } if event.Key() == tcell.KeyCtrlL { go func() { fetchLCPModelName() // blocks |
