diff --git a/README.md b/README.md index 435dd02..b666c55 100644 --- a/README.md +++ b/README.md @@ -141,17 +141,19 @@ We provide a `config.json` file within your directory for easy access to essenti "provider": "openai", // openai, gemini, openrouter "maxAttachmentSizeMb": 3, "includeReasoningTokensInContext": true, - "sessionExportDir": "/must/be/absolute/path/to/exports" + "sessionExportDir": "/must/be/absolute/path/to/exports", + "contextMaxDepth": 2 } ``` - - `providerBaseUrl`: The url can be anything that follows OpenAI API standard ( [ollama](http://localhost:11434), [lmstudio](http://127.0.0.1:1234), etc) - - `chatGPTApiUrl` [obsolete]: same as `providerBaseUrl` - - `systemMessage` field is available for customizing system prompt messages. **Better to set it from the app** - - `defaultModel` field sets the default model. **Better to set it from the app** - - `maxAttachmentSizeMb` field sets maximum allowed image size - - `includeReasoningTokensInContext` field sets whether to include reasoning tokens in the next request or not. - - `sessionExportDir` allows to specify directory for session exports. If not set, exports are saved to current directory. **The path must be an absolute path** + - `providerBaseUrl`: The url can be anything that follows OpenAI API standard ( [ollama](http://localhost:11434), [lmstudio](http://127.0.0.1:1234), etc) + - `chatGPTApiUrl` [obsolete]: same as `providerBaseUrl` + - `systemMessage` field is available for customizing system prompt messages. **Better to set it from the app** + - `defaultModel` field sets the default model. **Better to set it from the app** + - `maxAttachmentSizeMb` field sets maximum allowed image size + - `includeReasoningTokensInContext` field sets whether to include reasoning tokens in the next request or not. + - `sessionExportDir` allows to specify directory for session exports. If not set, exports are saved to current directory. **The path must be an absolute path** + - `contextMaxDepth`: Sets the maximum depth for recursive file search when using `@` to add file context. Default is `2`. Higher values allow searching deeper into subdirectories but may be slower. ### Providers @@ -253,6 +255,12 @@ nekot -n - `esc`: Exit insert mode for the prompt * When in 'Prompt editor' mode, pressing `esc` second time will close editor - `Ctrl+a`: open file picker for attaching images. You can also attach images by typing: [img=/path/to/image] +- `@`: Open context picker for adding file/folder context (type `@` at the beginning of input or after a space) + * Context picker shows a preview pane on the right for text files + * Use `Ctrl+/` to focus the filter input for searching files + * Filter performs recursive search through subdirectories (depth controlled by `contextMaxDepth` config) + * Preview shows line numbers and uses theme colors for better readability + * Binary files and directories show appropriate messages instead of preview ## Chat Messages Pane diff --git a/clients/gemini.go b/clients/gemini.go index ac9c25c..59ed875 100644 --- a/clients/gemini.go +++ b/clients/gemini.go @@ -442,6 +442,15 @@ func buildChatHistory(msgs []util.LocalStoreMessage, includeReasoning bool) ([]* messageContent += singleMessage.Content } + if singleMessage.ContextContent != "" { + util.Slog.Debug("Gemini: Including ContextContent in message", + "contextContentLength", len(singleMessage.ContextContent)) + if messageContent != "" { + messageContent += "\n\n" + } + messageContent += singleMessage.ContextContent + } + message := genai.Content{ Parts: []genai.Part{}, Role: role, diff --git a/clients/openai.go b/clients/openai.go index 8998b14..63a74ab 100644 --- a/clients/openai.go +++ b/clients/openai.go @@ -270,6 +270,15 @@ func (c OpenAiClient) constructCompletionRequestPayload( messageContent += singleMessage.Content } + if singleMessage.ContextContent != "" { + util.Slog.Debug("OpenAI: Including ContextContent in message", + "contextContentLength", len(singleMessage.ContextContent)) + if messageContent != "" { + messageContent += "\n\n" + } + messageContent += singleMessage.ContextContent + } + if messageContent != "" { singleMessage.Content = messageContent } diff --git a/clients/openrouter.go b/clients/openrouter.go index 7f138cf..6b96c24 100644 --- a/clients/openrouter.go +++ b/clients/openrouter.go @@ -267,6 +267,15 @@ func setRequestContext( messageContent += singleMessage.Content } + if singleMessage.ContextContent != "" { + util.Slog.Debug("OpenRouter: Including ContextContent in message", + "contextContentLength", len(singleMessage.ContextContent)) + if messageContent != "" { + messageContent += "\n\n" + } + messageContent += singleMessage.ContextContent + } + if len(singleMessage.ToolCalls) > 0 { toolCalls := constructOpenrouterToolCalls(singleMessage) chat = append(chat, toolCalls...) diff --git a/components/file-picker-preview.go b/components/file-picker-preview.go new file mode 100644 index 0000000..5d16cb5 --- /dev/null +++ b/components/file-picker-preview.go @@ -0,0 +1,261 @@ +package components + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + "unicode/utf8" + + "github.com/BalanceBalls/nekot/util" + "github.com/charmbracelet/lipgloss" +) + +// SetSize sets the dimensions for the file picker +func (m *FilePicker) SetSize(w, h int) { + if w > 2 && h > 2 { + m.filepicker.SetHeight(h) + m.terminalWidth = w + } +} + +// GetFilePreviewContent reads and returns the content of a file for preview +// Only reads content for text files, returns appropriate message for others +func (m FilePicker) GetFilePreviewContent(path string) string { + // Check if it's a directory + info, err := os.Stat(path) + if err != nil { + return "Error reading file: " + err.Error() + } + if info.IsDir() { + return "[Directory]" + } + + // Check if it's a text file + if !m.isTextFile(path) { + return "[Binary file - preview not available]" + } + + // Read file content + content, err := os.ReadFile(path) + if err != nil { + return "Error reading file: " + err.Error() + } + + // Convert to string and limit to reasonable size + contentStr := string(content) + if len(contentStr) > util.MaxPreviewContentSize { + contentStr = contentStr[:util.MaxPreviewContentSize] + "\n... (truncated)" + } + + return contentStr +} + +// GetPreviewView returns the preview pane view for the currently selected file +// Only shows preview for text files in context mode +// Adds colored output, line numbers, and better alignment +// Uses caching to avoid re-rendering on every call +func (m FilePicker) GetPreviewView(height int) string { + if !m.IsContextMode || m.previewFile == "" { + return "" + } + + // Check if it's a directory + info, err := os.Stat(m.previewFile) + if err != nil { + return lipgloss.NewStyle(). + Foreground(m.colors.ErrorColor). + Render("Error: " + err.Error()) + } + if info.IsDir() { + header := lipgloss.NewStyle(). + Foreground(m.colors.HighlightColor). + Bold(true). + Render("[Directory]") + path := lipgloss.NewStyle(). + Foreground(m.colors.DefaultTextColor). + Render(m.previewFile) + return lipgloss.JoinVertical(lipgloss.Left, header, path) + } + + // Check if we can use cached preview + // Cache is valid if: same file, same mtime, same terminal width + if m.cachedPreviewRendered != "" && + m.previewFile == m.cachedPreviewFile && + info.ModTime().Equal(m.cachedPreviewMtime) && + m.terminalWidth == m.cachedTerminalWidth { + return m.cachedPreviewRendered + } + + // Check if it's a text file - use cached result if available + // We need to re-check if the file changed + isText := m.cachedIsText + if m.cachedPreviewFile != m.previewFile { + // File changed, re-check text validity + isText = m.isTextFile(m.previewFile) + m.cachedIsText = isText + } + + if !isText { + header := lipgloss.NewStyle(). + Foreground(m.colors.HighlightColor). + Bold(true). + Render("[Binary File]") + message := lipgloss.NewStyle(). + Foreground(m.colors.DefaultTextColor). + Render("Preview not available for binary files") + return lipgloss.JoinVertical(lipgloss.Left, header, "", message) + } + + // Generate preview + lines := strings.Split(m.previewContent, "\n") + + // Limit to available height (reserve space for header) + availableHeight := height - 3 + if len(lines) > availableHeight { + lines = lines[:availableHeight] + } + + lineNumWidth := len(fmt.Sprintf("%d", len(lines))) + + // Calculate max line width (50% of terminal width) + maxLineWidth := (m.terminalWidth / 2) - lineNumWidth - 5 + + // truncate detection + hasLongLines := false + for _, line := range lines { + visibleLen := utf8.RuneCountInString(util.StripAnsiCodes(line)) + if visibleLen > maxLineWidth { + hasLongLines = true + break + } + } + + if hasLongLines { + for i := range lines { + visibleLen := utf8.RuneCountInString(util.StripAnsiCodes(lines[i])) + if visibleLen > maxLineWidth { + truncated := util.TruncateLineWithANSI(lines[i], maxLineWidth) + lines[i] = truncated + } + } + } + + // Build styled preview with line numbers + var previewLines []string + + // Add header with file info + fileName := filepath.Base(m.previewFile) + fileSize := util.FormatSize(info.Size()) + headerStyle := lipgloss.NewStyle(). + Foreground(m.colors.HighlightColor). + Bold(true) + header := headerStyle.Render(fmt.Sprintf("%s (%s)", fileName, fileSize)) + previewLines = append(previewLines, header) + + for i, line := range lines { + lineNum := fmt.Sprintf("%*d │ ", lineNumWidth, i+1) + lineNumStyle := lipgloss.NewStyle(). + Foreground(m.colors.AccentColor) + contentStyle := lipgloss.NewStyle(). + Foreground(m.colors.DefaultTextColor) + previewLines = append(previewLines, lineNumStyle.Render(lineNum)+contentStyle.Render(line)) + } + + // Cache the rendered preview + rendered := strings.Join(previewLines, "\n") + m.cachedPreviewRendered = rendered + m.cachedPreviewFile = m.previewFile + m.cachedPreviewMtime = info.ModTime() + m.cachedTerminalWidth = m.terminalWidth + m.cachedIsText = true + + return rendered +} + +// UpdatePreviewFromView extracts the currently selected file from filepicker's rendered view +// This allows preview to update when navigating with arrow keys, not just on Enter +func (m *FilePicker) UpdatePreviewFromView(view string) { + if !m.IsContextMode { + return + } + + // Parse the view to find the currently selected file (marked with cursor) + lines := strings.Split(view, "\n") + for _, line := range lines { + // Look for cursor indicator (filepicker uses > for cursor) + if strings.Contains(line, ">") { + // Strip ANSI escape codes + cleanLine := util.StripAnsiCodes(line) + + // Extract file path from the line + // Format: "> 4.1kB clients" or "> 18kB chat-pane.go" + // The format is: cursor + spaces + size + spaces + filename + parts := strings.Split(cleanLine, ">") + if len(parts) > 1 { + rest := strings.TrimSpace(parts[1]) + + // Split by spaces to get size and filename + // Format: "4.1kB clients" -> ["4.1kB", "clients"] + fields := strings.Fields(rest) + if len(fields) >= 2 { + // The filename is the last field + fileName := fields[len(fields)-1] + + // Get full path by combining with current directory + if !strings.HasPrefix(fileName, "/") && !strings.HasPrefix(fileName, "~") { + // Relative path + fullPath := filepath.Join(m.filepicker.CurrentDirectory, fileName) + + // Check if file exists + if _, err := os.Stat(fullPath); err != nil { + util.Slog.Error("FilePicker: File does not exist", "path", fullPath, "error", err) + } + + // Only update if different from current preview + if fullPath != m.previewFile { + m.previewFile = fullPath + m.previewContent = m.GetFilePreviewContent(fullPath) + } + } else { + if fileName != m.previewFile { + m.previewFile = fileName + m.previewContent = m.GetFilePreviewContent(fileName) + } + } + } else { + util.Slog.Warn("FilePicker: Could not parse filename from line", "fields", fields) + } + } + } + } +} + +// GetPreviewFile returns the currently selected file for preview +func (m FilePicker) GetPreviewFile() string { + return m.previewFile +} + +// GetPreviewContent returns the preview content for the currently selected file +func (m FilePicker) GetContent() string { + return m.previewContent +} + +// RefreshPreview refreshes the preview for the current file +func (m *FilePicker) RefreshPreview() { + if m.previewFile != "" { + m.previewContent = m.GetFilePreviewContent(m.previewFile) + // Invalidate cache + m.cachedPreviewRendered = "" + } +} + +// ClearPreview clears the preview content and cache +func (m *FilePicker) ClearPreview() { + m.previewFile = "" + m.previewContent = "" + m.cachedPreviewRendered = "" + m.cachedPreviewFile = "" + m.cachedPreviewMtime = time.Time{} +} diff --git a/components/file-picker-search.go b/components/file-picker-search.go new file mode 100644 index 0000000..8addffe --- /dev/null +++ b/components/file-picker-search.go @@ -0,0 +1,237 @@ +package components + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/BalanceBalls/nekot/util" + "github.com/charmbracelet/lipgloss" +) + +// RecursiveSearch performs a recursive search for files matching the filter text +// Searches up to maxDepth levels deep from the current directory +// Returns at most maxSearchResults to prevent memory/performance issues +func (m *FilePicker) RecursiveSearch(filterText string, maxDepth int) []util.SearchResult { + var results []util.SearchResult + currentDir := m.filepicker.CurrentDirectory + + // Use the new WalkDirectory utility + _, err := util.WalkDirectory(currentDir, maxDepth, func(filePath string, d fs.DirEntry, relPath string, depth int) bool { + // Stop collecting results if we've reached the limit + if len(results) >= util.MaxSearchResults { + return false // Return false to stop walking + } + + // Skip media files in context mode + if m.IsContextMode && util.IsMediaFile(filePath) { + return false + } + + // Check if the entry name contains the filter text (case-insensitive) + baseName := filepath.Base(filePath) + if strings.Contains(strings.ToLower(baseName), filterText) { + info, err := d.Info() + if err != nil { + return false + } + + results = append(results, util.SearchResult{ + Path: filePath, + RelPath: relPath, + IsDir: d.IsDir(), + Size: info.Size(), + }) + return true + } + + return false + }) + + if err != nil { + // Log error but return what we have + util.Slog.Warn("FilePicker: Error during recursive search", "error", err) + } + + return results +} + +// FilterFilePickerView returns a filtered view of the file picker +// Uses cached search results from debounced search in Update +func (m *FilePicker) FilterFilePickerView(filterText string) string { + // Get the current directory from the file picker + currentDir := m.filepicker.CurrentDirectory + + // In context mode, use cached search results (populated by debounced Update) + // Don't perform search here - it defeats the debounce mechanism + if m.IsContextMode { + // Use already-cached searchResults from Update + // (searchResults is populated by debounced search in Update method) + + // Only reset selection index if it's out of bounds or if there are no results + // Don't reset if the user has already navigated with arrow keys + if len(m.searchResults) > 0 { + if m.filteredSelectionIndex < 0 || m.filteredSelectionIndex >= len(m.searchResults) { + m.filteredSelectionIndex = 0 + } + } else { + m.filteredSelectionIndex = -1 + } + + // If no matches, show a message + if len(m.searchResults) == 0 { + return currentDir + "\n\nNo files match filter: " + m.filterInput.Value() + } + + // Build the filtered view with relative paths + var lines []string + lines = append(lines, currentDir) + lines = append(lines, "") + + for i, result := range m.searchResults { + // Format the entry line with relative path + name := result.RelPath + if result.IsDir { + name += "/" + } + + // Add size info + sizeStr := util.FormatSize(result.Size) + + // Add indentation based on depth for visual hierarchy + depth := strings.Count(result.RelPath, string(filepath.Separator)) + indent := strings.Repeat(" ", depth) + + // Determine if this item is selected + isSelected := i == m.filteredSelectionIndex + + // Add cursor indicator for selected item + prefix := " " + if isSelected { + prefix = "> " + } + + // Apply colors based on selection and file type + var styledLine string + if isSelected { + // Selected item: use ActiveTabBorderColor for both name and cursor + cursorStyle := lipgloss.NewStyle().Foreground(m.colors.ActiveTabBorderColor) + nameStyle := lipgloss.NewStyle().Foreground(m.colors.ActiveTabBorderColor) + sizeStyle := lipgloss.NewStyle().Foreground(m.colors.ActiveTabBorderColor) + styledLine = cursorStyle.Render(prefix) + indent + nameStyle.Render(name) + " " + sizeStyle.Render(sizeStr) + } else { + // Non-selected item: use different colors for directories vs files + var nameStyle lipgloss.Style + if result.IsDir { + nameStyle = lipgloss.NewStyle().Foreground(m.colors.HighlightColor) + } else { + nameStyle = lipgloss.NewStyle().Foreground(m.colors.NormalTabBorderColor) + } + // Use a subdued color for file size + sizeStyle := lipgloss.NewStyle().Foreground(m.colors.HighlightColor).Faint(true) + styledLine = prefix + indent + nameStyle.Render(name) + " " + sizeStyle.Render(sizeStr) + } + + lines = append(lines, styledLine) + } + + return strings.Join(lines, "\n") + } + + // Non-context mode: use current directory only + // Use cached directory entries + entries := m.cachedDirEntries + if len(entries) == 0 || m.cachedDirPath != currentDir { + // Cache miss or directory changed, read directory + var err error + entries, err = os.ReadDir(currentDir) + if err != nil { + return "Error reading directory: " + err.Error() + } + } + + // Filter entries based on the filter text + var filteredEntries []os.DirEntry + for _, entry := range entries { + // Check if the entry name contains the filter text + if strings.Contains(strings.ToLower(entry.Name()), filterText) { + filteredEntries = append(filteredEntries, entry) + } + } + + // If no matches, show a message + if len(filteredEntries) == 0 { + return currentDir + "\n\nNo files match filter: " + m.filterInput.Value() + } + + // Build the filtered view + var lines []string + lines = append(lines, currentDir) + lines = append(lines, "") + + for _, entry := range filteredEntries { + // Get file info + info, err := entry.Info() + if err != nil { + continue + } + + // Format the entry line + name := entry.Name() + if info.IsDir() { + name += "/" + } + + // Add size info + size := info.Size() + sizeStr := util.FormatSize(size) + + lines = append(lines, name+" "+sizeStr) + } + + return strings.Join(lines, "\n") +} + +// UpdateSearchResults updates the search results based on the filter text +func (m *FilePicker) UpdateSearchResults(filterText string) { + if filterText != "" && m.IsContextMode { + m.searchResults = m.RecursiveSearch(filterText, m.searchDepth) + if len(m.searchResults) > 0 { + m.filteredSelectionIndex = 0 + } else { + m.filteredSelectionIndex = -1 + } + } else { + m.searchResults = []util.SearchResult{} + m.filteredSelectionIndex = -1 + } +} + +// GetSelectedSearchResult returns the currently selected search result +func (m *FilePicker) GetSelectedSearchResult() *util.SearchResult { + if len(m.searchResults) > 0 && m.filteredSelectionIndex >= 0 && m.filteredSelectionIndex < len(m.searchResults) { + return &m.searchResults[m.filteredSelectionIndex] + } + return nil +} + +// NavigateSearchResults moves the selection up or down in the search results +func (m *FilePicker) NavigateSearchResults(direction int) { + if len(m.searchResults) == 0 { + return + } + + m.filteredSelectionIndex += direction + if m.filteredSelectionIndex < 0 { + m.filteredSelectionIndex = len(m.searchResults) - 1 + } else if m.filteredSelectionIndex >= len(m.searchResults) { + m.filteredSelectionIndex = 0 + } +} + +// ClearSearch clears the search results and resets the selection +func (m *FilePicker) ClearSearch() { + m.searchResults = []util.SearchResult{} + m.filteredSelectionIndex = -1 +} diff --git a/components/file-picker.go b/components/file-picker.go index 4d28986..870a902 100644 --- a/components/file-picker.go +++ b/components/file-picker.go @@ -2,14 +2,25 @@ package components import ( "errors" + "io" "os" + "path/filepath" + "strings" "time" + "unicode/utf8" "github.com/BalanceBalls/nekot/util" "github.com/charmbracelet/bubbles/filepicker" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) +// Debounce message for delayed search +type debounceSearchMsg struct { + filterText string +} + type FilePicker struct { SelectedFile string PrevView util.ViewMode @@ -17,12 +28,54 @@ type FilePicker struct { filepicker filepicker.Model quitting bool err error + // For context picker mode + IsContextMode bool + // Track if filter input is focused + filterInputFocused bool + // Filter input for searching files + filterInput textinput.Model + // Filtered files list + filteredFiles []os.DirEntry + // Search results for recursive fuzzy matching + searchResults []util.SearchResult + // Search depth from config + searchDepth int + // Theme colors for styling + colors util.SchemeColors + // Currently selected file for preview (for context mode) + previewFile string + // Preview content + previewContent string + + // Caching + cachedPreviewRendered string + cachedPreviewFile string + cachedPreviewMtime time.Time + cachedTerminalWidth int + cachedIsText bool + cachedDirEntries []os.DirEntry + cachedDirPath string + // Text file validation cache: path -> isText + textFileCache map[string]bool + textFileCacheMtime map[string]time.Time + + // Terminal width for line truncation in preview + terminalWidth int + // Selection index for filtered view (when filter is active) + filteredSelectionIndex int + // Debounce timer and channel for recursive search + debounceTimer *time.Timer + debounceChannel chan string // Channel to signal when debounce completes + // Timestamp of last search for rate limiting + lastSearchTime time.Time } func NewFilePicker( prevView util.ViewMode, prevInput string, colors util.SchemeColors, + isContextMode bool, + searchDepth int, ) FilePicker { fp := filepicker.New() @@ -38,19 +91,108 @@ func NewFilePicker( fp.Styles.Selected = fp.Styles.Selected. Foreground(colors.ActiveTabBorderColor) - fp.AllowedTypes = []string{".png", ".jpg", ".jpeg", ".webp", ".gif"} - fp.CurrentDirectory, _ = os.UserHomeDir() + if isContextMode { + // Perhaps only allow non-media? + // Because this is a seperate mode, the logic should be here + + // Note: Media files will be filtered out during selection processing + fp.DirAllowed = true + fp.AllowedTypes = []string{} + } else { + // For media mode, only allow media files (images, videos, audio, etc.) + fp.AllowedTypes = util.MediaExtensions + } + + fp.CurrentDirectory, _ = os.Getwd() fp.ShowPermissions = false fp.ShowSize = true + // Initialize filter input + filterInput := textinput.New() + filterInput.Placeholder = "Filter files..." + filterInput.Prompt = "Filter: " + filterInput.PromptStyle = lipgloss.NewStyle().Foreground(colors.ActiveTabBorderColor) + filterInput.PlaceholderStyle = lipgloss.NewStyle().Faint(true) + filePicker := FilePicker{ - filepicker: fp, - PrevView: prevView, - PrevInputData: prevInput, + filepicker: fp, + PrevView: prevView, + PrevInputData: prevInput, + IsContextMode: isContextMode, + filterInput: filterInput, + filterInputFocused: false, + filteredFiles: []os.DirEntry{}, + searchResults: []util.SearchResult{}, + searchDepth: searchDepth, + colors: colors, + debounceChannel: make(chan string, 1), + textFileCache: make(map[string]bool), + textFileCacheMtime: make(map[string]time.Time), } return filePicker } +// Stop cleans up resources used by the file picker +// Should be called when the file picker is no longer needed +func (m *FilePicker) Stop() { + if m.debounceTimer != nil { + m.debounceTimer.Stop() + } + // Clear the channel to prevent goroutine leaks + if m.debounceChannel != nil { + for { + select { + case <-m.debounceChannel: + default: + goto done + } + } + done: + } +} + +// isTextFile checks if a file is a text file using cached results +// Uses a map cache to avoid re-reading file content for the same file +func (m *FilePicker) isTextFile(path string) bool { + // Check cache first + if cached, ok := m.textFileCache[path]; ok { + return cached + } + + // Check extension against known text/code extensions using helper function + ext := filepath.Ext(path) + if util.IsTextOrCodeExtension(ext) { + m.textFileCache[path] = true + return true + } + + // Check file size first - skip very large files (likely binary or not suitable for preview) + fileInfo, err := os.Stat(path) + if err != nil { + return false + } + // Skip files larger than 1MB as they're unlikely to be suitable for quick preview + if fileInfo.Size() > util.MaxPreviewFileSize { + return false + } + + // Try to read a small portion to check if it's valid UTF-8 + // Use os.Open with limited read instead of ReadFile to avoid loading entire file + file, err := os.Open(path) + if err != nil { + return false + } + defer file.Close() + + // Read only first 1024 bytes for UTF-8 validity check + buf := make([]byte, util.Utf8CheckBufferSize) + n, err := file.Read(buf) + if err != nil && err != io.EOF { + return false + } + return utf8.Valid(buf[:n]) +} + type clearErrorMsg struct{} func clearErrorAfter(t time.Duration) tea.Cmd { @@ -66,10 +208,77 @@ func (m FilePicker) Init() tea.Cmd { func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - switch msg.String() { - case "esc", "q": - m.quitting = true - return m, util.SendViewModeChangedMsg(m.PrevView) + keyStr := msg.String() + + // Handle Ctrl+/ (or Ctrl+_ on some keyboards) to focus filter input + if keyStr == "ctrl+/" || keyStr == "ctrl+_" { + m.filterInputFocused = true + m.filterInput.Focus() + // Initialize search results if filter has content + filterText := strings.ToLower(m.filterInput.Value()) + if filterText != "" && m.IsContextMode { + m.searchResults = m.RecursiveSearch(filterText, m.searchDepth) + // Reset selection index + if len(m.searchResults) > 0 { + m.filteredSelectionIndex = 0 + } else { + m.filteredSelectionIndex = -1 + } + } + return m, nil + } + + switch keyStr { + case "esc": + // Two-stage Esc behavior: + // - First press: blur filter input if focused + // - Second press: close the picker + if m.filterInputFocused { + m.filterInputFocused = false + m.filterInput.Blur() + + // Don't pass Esc to filepicker to prevent going back + return m, nil + } else { + // Filter input is not focused, close the picker + m.quitting = true + return m, util.SendViewModeChangedMsg(m.PrevView) + } + case "enter": + // Handle Enter key to select file when filter is active and there are search results + if m.filterInputFocused && len(m.searchResults) > 0 && m.filteredSelectionIndex >= 0 && m.filteredSelectionIndex < len(m.searchResults) { + selectedResult := m.searchResults[m.filteredSelectionIndex] + m.SelectedFile = selectedResult.Path + return m, nil + } + case "up", "down": + + // Handle arrow keys for filtered view when filter is active and there are search results + if m.filterInputFocused && len(m.searchResults) > 0 { + if keyStr == "up" { + m.filteredSelectionIndex-- + if m.filteredSelectionIndex < 0 { + m.filteredSelectionIndex = len(m.searchResults) - 1 + } + } else if keyStr == "down" { + m.filteredSelectionIndex++ + if m.filteredSelectionIndex >= len(m.searchResults) { + m.filteredSelectionIndex = 0 + } + } + + // Update preview for the selected file + if m.filteredSelectionIndex >= 0 && m.filteredSelectionIndex < len(m.searchResults) { + selectedResult := m.searchResults[m.filteredSelectionIndex] + if selectedResult.Path != m.previewFile { + m.previewFile = selectedResult.Path + m.previewContent = m.GetFilePreviewContent(selectedResult.Path) + } + } + + // Don't pass arrow keys to filter input when navigating filtered results + return m, nil + } } case clearErrorMsg: @@ -77,30 +286,175 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { } var cmd tea.Cmd + var filterCmd tea.Cmd + + // Update filter input if focused + if m.filterInputFocused { + oldFilterValue := m.filterInput.Value() + m.filterInput, filterCmd = m.filterInput.Update(msg) + newFilterValue := m.filterInput.Value() + + // If filter value changed, set up debounced search + if oldFilterValue != newFilterValue { + filterText := strings.ToLower(newFilterValue) + + // Reset existing timer + if m.debounceTimer != nil { + m.debounceTimer.Stop() + } + + // Schedule debounced search using AfterFunc + // When timer fires, it sends the filter text to the channel + m.debounceTimer = time.AfterFunc(util.FilePickerDebounce, func() { + // Non-blocking send to channel + select { + case m.debounceChannel <- filterText: + default: + } + }) + } + + // Don't pass key messages to filepicker when filter input is focused + // This prevents Backspace from being interpreted as "go up one directory" + if _, ok := msg.(tea.KeyMsg); ok { + return m, filterCmd + } + } + + // Check if debounce channel has a message (timer fired) + // Use non-blocking select to check for channel message + if m.debounceChannel != nil { + select { + case filterText := <-m.debounceChannel: + // Timer fired - perform the debounced search + if filterText != "" && m.IsContextMode { + m.searchResults = m.RecursiveSearch(filterText, m.searchDepth) + if len(m.searchResults) > 0 { + m.filteredSelectionIndex = 0 + } else { + m.filteredSelectionIndex = -1 + } + } else { + m.searchResults = []util.SearchResult{} + m.filteredSelectionIndex = -1 + } + m.lastSearchTime = time.Now() + default: + // No message waiting, continue + } + } + + // Update filepicker m.filepicker, cmd = m.filepicker.Update(msg) + // Track selection changes via cursor position for filtered view, + // and via view parsing for normal navigation (filepicker doesn't expose cursor) + // We track key presses to detect navigation changes + if m.filterInputFocused && len(m.searchResults) > 0 { + // For filtered view, use tracked filteredSelectionIndex (already maintained) + // Preview is updated in the key handler above + } else { + // For normal navigation, use view parsing (necessary as library doesn't expose cursor) + // But only re-parse if the view actually changed + currentView := m.filepicker.View() + if currentView != m.cachedPreviewRendered { + // View changed - this could be navigation or just window resize + // Parse to find selected file + m.UpdatePreviewFromView(currentView) + } + } + + // Refresh cache if directory changed + if m.cachedDirPath != m.filepicker.CurrentDirectory { + entries, err := os.ReadDir(m.filepicker.CurrentDirectory) + if err == nil { + m.cachedDirEntries = entries + m.cachedDirPath = m.filepicker.CurrentDirectory + } + } + if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect { + // In context mode, filter out media files + if m.IsContextMode && util.IsMediaFile(path) { + m.err = errors.New(path + " is a media file. Use Ctrl+A to attach media files.") + m.SelectedFile = "" + return m, tea.Batch(cmd, filterCmd, clearErrorAfter(util.ErrorDisplayDuration)) + } m.SelectedFile = path + // Update preview file for context mode + if m.IsContextMode { + m.previewFile = path + m.previewContent = m.GetFilePreviewContent(path) + } } if didSelect, path := m.filepicker.DidSelectDisabledFile(msg); didSelect { m.err = errors.New(path + " is not valid.") m.SelectedFile = "" - return m, tea.Batch(cmd, clearErrorAfter(2*time.Second)) + return m, tea.Batch(cmd, filterCmd, clearErrorAfter(util.ErrorDisplayDuration)) } - return m, cmd + return m, tea.Batch(cmd, filterCmd) } func (m FilePicker) View() string { if m.quitting { return "" } - return m.filepicker.View() + + // Get the file picker view + filePickerView := m.filepicker.View() + + // If filter input has content, filter the file picker view + filterText := strings.ToLower(m.filterInput.Value()) + if filterText != "" { + filePickerView = m.FilterFilePickerView(filterText) + } + + // Only show filter input when focused or has value + if m.IsFilterInputVisible() { + filterInputView := m.filterInput.View() + // Join file picker view and filter input vertically + return lipgloss.JoinVertical( + lipgloss.Left, + filePickerView, + filterInputView, + ) + } + + return filePickerView } -func (m *FilePicker) SetSize(w, h int) { - if w > 2 && h > 2 { - m.filepicker.SetHeight(h) +// GetFilePickerViewWithoutFilter returns the file picker view without the filter input +// This is used when the filter input is displayed separately (e.g., below preview) +func (m FilePicker) GetFilePickerViewWithoutFilter() string { + if m.quitting { + return "" + } + + // Get the file picker view + filePickerView := m.filepicker.View() + + // If filter input has content, filter the file picker view + filterText := strings.ToLower(m.filterInput.Value()) + if filterText != "" { + filePickerView = m.FilterFilePickerView(filterText) + } + + return filePickerView +} + +// IsFilterInputVisible returns true if the filter input should be shown +// Filter is shown only when it's focused or has a value +func (m FilePicker) IsFilterInputVisible() bool { + return m.filterInputFocused || m.filterInput.Value() != "" +} + +// GetFilterInputView returns the filter input view +// Returns empty string if filter should not be visible +func (m FilePicker) GetFilterInputView() string { + if !m.IsFilterInputVisible() { + return "" } + return m.filterInput.View() } diff --git a/config/config-handler.go b/config/config-handler.go index 57a7607..af8b9ec 100644 --- a/config/config-handler.go +++ b/config/config-handler.go @@ -53,6 +53,7 @@ type Config struct { MaxAttachmentSizeMb int `json:"maxAttachmentSizeMb"` IncludeReasoningTokensInContext *bool `json:"includeReasoningTokensInContext"` SessionExportDir string `json:"sessionExportDir"` + ContextMaxDepth *int `json:"contextMaxDepth"` } type StartupFlags struct { @@ -229,6 +230,20 @@ func (c *Config) setDefaults() { if c.IncludeReasoningTokensInContext == nil { c.IncludeReasoningTokensInContext = &TRUE } + + // Set default context max depth to 2 for recursive file scanning + if c.ContextMaxDepth == nil { + defaultDepth := 2 + c.ContextMaxDepth = &defaultDepth + } +} + +// GetContextMaxDepth returns the context max depth, defaulting to 2 if nil +func (c *Config) GetContextMaxDepth() int { + if c.ContextMaxDepth == nil { + return 2 + } + return *c.ContextMaxDepth } func (c *Config) applyFlags(flags StartupFlags) { diff --git a/config/config.json b/config/config.json index 2704b59..e67c548 100644 --- a/config/config.json +++ b/config/config.json @@ -6,5 +6,6 @@ "provider": "openai", "maxAttachmentSizeMb": 10, "includeReasoningTokensInContext": true, - "sessionExportDir": "" + "sessionExportDir": "", + "contextMaxDepth": 2 } diff --git a/panes/chat-pane.go b/panes/chat-pane.go index 8cf75a4..a1683ac 100644 --- a/panes/chat-pane.go +++ b/panes/chat-pane.go @@ -27,12 +27,13 @@ const ( ) type chatPaneKeyMap struct { - selectionMode key.Binding - exit key.Binding - copyLast key.Binding - copyAll key.Binding - goUp key.Binding - goDown key.Binding + selectionMode key.Binding + exit key.Binding + copyLast key.Binding + copyAll key.Binding + goUp key.Binding + goDown key.Binding + toggleContextContent key.Binding } var defaultChatPaneKeyMap = chatPaneKeyMap{ @@ -60,6 +61,10 @@ var defaultChatPaneKeyMap = chatPaneKeyMap{ key.WithKeys("G"), key.WithHelp("G", "scroll to bottom"), ), + toggleContextContent: key.NewBinding( + key.WithKeys("ctrl+g"), + key.WithHelp("ctrl+g", "toggle context content visibility"), + ), } const pulsarIntervalMs = 100 @@ -86,6 +91,7 @@ type ChatPane struct { idleCyclesCount int processingState util.ProcessingState currentSettings util.Settings + showContextContent bool mu *sync.RWMutex terminalWidth int @@ -196,6 +202,16 @@ func (p ChatPane) Update(msg tea.Msg) (ChatPane, tea.Cmd) { return p, nil + case util.ToggleContextContent: + p.showContextContent = !p.showContextContent + // Trigger re-render + return p, func() tea.Msg { + return tea.WindowSizeMsg{ + Width: p.terminalWidth, + Height: p.terminalHeight, + } + } + case util.ProcessingStateChanged: p.mu.Lock() defer p.mu.Unlock() @@ -290,7 +306,8 @@ func (p ChatPane) Update(msg tea.Msg) (ChatPane, tea.Cmd) { paneWidth, p.colors, p.quickChatActive, - p.currentSettings) + p.currentSettings, + p.showContextContent) p.sessionContent = msg.PreviousMsgArray util.Slog.Debug("len(p.sessionContent) != len(msg.PreviousMsgArray)", "new length", len(msg.PreviousMsgArray)) } @@ -398,6 +415,14 @@ func (p ChatPane) Update(msg tea.Msg) (ChatPane, tea.Cmd) { } cmds = append(cmds, copyAll) } + + case key.Matches(msg, p.keyMap.toggleContextContent): + if p.isChatContainerFocused { + toggleCmd := func() tea.Msg { + return util.SendToggleContextContentMsg() + } + cmds = append(cmds, toggleCmd) + } } } @@ -434,7 +459,8 @@ func (p *ChatPane) enterSelectionMode() { p.sessionContent, p.chatView.Width, p.colors, - p.currentSettings) + p.currentSettings, + p.showContextContent) mouseTopOffset := p.chatContainer.GetMarginTop() + p.chatContainer.GetBorderTopSize() + p.chatContainer.GetPaddingTop() mouseLeftOffset := p.chatContainer.GetMarginLeft() + p.chatContainer.GetBorderLeftSize() + p.chatContainer.GetPaddingLeft() p.selectionView = components.NewTextSelector( @@ -629,7 +655,8 @@ func (p ChatPane) displaySession( paneWidth-1, p.colors, p.quickChatActive, - p.currentSettings) + p.currentSettings, + p.showContextContent) p.chatView.SetContent(oldContent) if useScroll { p.chatView.GotoBottom() diff --git a/panes/prompt-pane.go b/panes/prompt-pane.go index f0e198a..a99885e 100644 --- a/panes/prompt-pane.go +++ b/panes/prompt-pane.go @@ -2,9 +2,11 @@ package panes import ( "context" + "os" "path/filepath" "regexp" "strings" + "unicode/utf8" "github.com/BalanceBalls/nekot/components" "github.com/BalanceBalls/nekot/config" @@ -79,9 +81,11 @@ type PromptPane struct { inputMode util.PrompInputMode colors util.SchemeColors keys keyMap + config config.Config pendingInsert string attachments []util.Attachment + contextChips []util.FileContextChip operation util.Operation viewMode util.ViewMode isSessionIdle bool @@ -140,6 +144,7 @@ func NewPromptPane(ctx context.Context) PromptPane { keys: defaultKeyMap, viewMode: util.NormalMode, colors: colors, + config: *config, input: input, textEditor: textEditor, inputContainer: container, @@ -227,7 +232,9 @@ func (p PromptPane) Update(msg tea.Msg) (PromptPane, tea.Cmd) { func (p *PromptPane) keyAttach() tea.Cmd { if p.isFocused && p.operation == util.NoOperaton && p.viewMode != util.FilePickerMode { - return util.SendViewModeChangedMsg(util.FilePickerMode) + // Set IsContextMode to false for image attachment + p.filePicker.IsContextMode = false + return util.SendViewModeChangedWithContextMsg(util.FilePickerMode, false) } else { return util.SendViewModeChangedMsg(util.NormalMode) } @@ -251,6 +258,7 @@ func (p *PromptPane) keyInsert() tea.Cmd { func (p *PromptPane) keyClear() tea.Cmd { p.attachments = []util.Attachment{} + p.contextChips = []util.FileContextChip{} switch p.viewMode { case util.TextEditMode: p.textEditor.Reset() @@ -329,8 +337,10 @@ func (p *PromptPane) keyEnter() tea.Cmd { } p.attachments = []util.Attachment{} + contextChips := p.contextChips + p.contextChips = []util.FileContextChip{} return tea.Batch( - util.SendPromptReadyMsg(promptText, attachments), + util.SendPromptReadyWithContextMsg(promptText, attachments, contextChips), util.SendViewModeChangedMsg(util.NormalMode)) default: @@ -345,7 +355,9 @@ func (p *PromptPane) keyEnter() tea.Cmd { p.inputMode = util.PromptNormalMode p.attachments = []util.Attachment{} - return util.SendPromptReadyMsg(promptText, attachments) + contextChips := p.contextChips + p.contextChips = []util.FileContextChip{} + return util.SendPromptReadyWithContextMsg(promptText, attachments, contextChips) } return nil @@ -405,15 +417,79 @@ func (p *PromptPane) processFilePickerUpdates(msg tea.Msg) tea.Cmd { var cmd tea.Cmd var cmds []tea.Cmd + // Debug: Log when processing file picker updates + if p.viewMode == util.FilePickerMode { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + util.Slog.Debug("PromptPane: Processing file picker update", "isFocused", p.isFocused, "keyStr", keyMsg.String()) + } + } + if p.isFocused && p.viewMode == util.FilePickerMode { if p.filePicker.SelectedFile != "" { - attachmentPath := p.filePicker.SelectedFile - attachmentPath = filepath.Clean(attachmentPath) - attachmentPath = strings.ReplaceAll(attachmentPath, `\ `, " ") - p.attachments = append(p.attachments, util.Attachment{ - Type: "img", - Path: attachmentPath, - }) + selectedPath := p.filePicker.SelectedFile + selectedPath = filepath.Clean(selectedPath) + selectedPath = strings.ReplaceAll(selectedPath, `\ `, " ") + + if p.filePicker.IsContextMode { + // Create a context chip from the selected path + info, err := os.Stat(selectedPath) + if err != nil { + util.Slog.Error("os.Stat failed for context chip", "path", selectedPath, "error", err.Error()) + } else { + // Check for duplicates before adding + isDuplicate := false + for _, existingChip := range p.contextChips { + if existingChip.Path == selectedPath { + isDuplicate = true + break + } + } + + if !isDuplicate { + chip := util.FileContextChip{ + Path: selectedPath, + Name: filepath.Base(selectedPath), + IsFolder: info.IsDir(), + Size: info.Size(), + } + + if info.IsDir() { + // Count files in the folder using the configured max depth + searchDepth := p.config.GetContextMaxDepth() + fileCount, err := util.CountFilesInFolder(selectedPath, searchDepth) + if err != nil { + util.Slog.Error("Failed to count files in folder", "path", selectedPath, "error", err.Error()) + fileCount = 0 + } + chip.FileCount = fileCount + } + + p.contextChips = append(p.contextChips, chip) + } else { + util.Slog.Debug("Skipping duplicate context chip", "path", selectedPath) + } + } + } else { + // Add as image attachment + // Check for duplicates before adding + isDuplicate := false + for _, existingAttachment := range p.attachments { + if existingAttachment.Path == selectedPath { + isDuplicate = true + break + } + } + + if !isDuplicate { + util.Slog.Debug("Adding image attachment", "path", selectedPath, "total_attachments", len(p.attachments)+1) + p.attachments = append(p.attachments, util.Attachment{ + Type: "img", + Path: selectedPath, + }) + } else { + util.Slog.Debug("Skipping duplicate image attachment", "path", selectedPath) + } + } cmds = append(cmds, util.SendViewModeChangedMsg(p.filePicker.PrevView)) p.filePicker.SelectedFile = "" @@ -438,8 +514,35 @@ func (p *PromptPane) processTextInputUpdates(msg tea.Msg) tea.Cmd { case util.FilePickerMode: break case util.TextEditMode: + // Check for @ trigger to open context picker + if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "@" { + // Get editor content to check position + editorContent := p.textEditor.Value() + // Check if @ is at start or after space + if len(editorContent) == 0 { + p.filePicker.IsContextMode = true + return util.SendViewModeChangedWithContextMsg(util.FilePickerMode, true) + } + // Check if last character is a space (handles UTF-8 properly) + lastRune, _ := utf8.DecodeLastRuneInString(editorContent) + if lastRune == ' ' { + p.filePicker.IsContextMode = true + return util.SendViewModeChangedWithContextMsg(util.FilePickerMode, true) + } + } p.textEditor, cmd = p.textEditor.Update(msg) default: + // Check for @ trigger to open context picker + if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "@" { + // Only trigger if at beginning of input or after space + currentValue := p.input.Value() + if len(currentValue) == 0 || currentValue[len(currentValue)-1] == ' ' { + // Set IsContextMode to true and open file picker + p.filePicker.IsContextMode = true + return util.SendViewModeChangedWithContextMsg(util.FilePickerMode, true) + } + } + // TODO: maybe there is a way to adjust heihgt for long inputs? // TODO: move to dimensions? if lipgloss.Width(p.input.Value()) > p.input.Width-4 { @@ -561,7 +664,8 @@ func (p *PromptPane) openInputField(previousViewMode util.ViewMode, currentInput func (p *PromptPane) openFilePicker(previousViewMode util.ViewMode, currentInput string) tea.Cmd { w, h := util.CalcPromptPaneSize(p.terminalWidth, p.terminalHeight, p.viewMode) - p.filePicker = components.NewFilePicker(previousViewMode, currentInput, p.colors) + searchDepth := p.config.GetContextMaxDepth() + p.filePicker = components.NewFilePicker(previousViewMode, currentInput, p.colors, p.filePicker.IsContextMode, searchDepth) p.filePicker.SetSize(w, h) return p.filePicker.Init() } @@ -689,16 +793,23 @@ func (p PromptPane) View() string { content = p.input.View() } - infoBlockContent := infoLabel.Render("Use ctrl+a to attach an image") + infoBlockContent := infoLabel.Render("Use ctrl+a to attach an image • @ to add file context") - if len(p.attachments) != 0 { - imageBlocks := []string{infoPrefix.Render("Attachments: ")} + if len(p.attachments) != 0 || len(p.contextChips) != 0 { + blocks := []string{infoPrefix.Render("Attachments: ")} for _, image := range p.attachments { fileName := filepath.Base(image.Path) - imageBlocks = append(imageBlocks, infoLabel.Render(fileName)) + blocks = append(blocks, infoLabel.Render(fileName)) + } + for _, chip := range p.contextChips { + chipText := chip.Name + if chip.IsFolder { + chipText += " (folder)" + } + blocks = append(blocks, infoLabel.Render(chipText)) } - infoBlockContent = lipgloss.JoinHorizontal(lipgloss.Left, imageBlocks...) + infoBlockContent = lipgloss.JoinHorizontal(lipgloss.Left, blocks...) } if p.operation == util.SystemMessageEditing { @@ -713,3 +824,49 @@ func (p PromptPane) View() string { return zone.Mark("prompt_pane", p.inputContainer.Render(ResponseWaitingMsg)) } + +// GetFilePickerView returns the file picker view for rendering in main view +func (p *PromptPane) GetFilePickerView() string { + return p.filePicker.View() +} + +// GetFilePickerViewWithoutFilter returns the file picker view without filter input +// This is used when the filter input is displayed separately (e.g., below preview) +func (p *PromptPane) GetFilePickerViewWithoutFilter() string { + return p.filePicker.GetFilePickerViewWithoutFilter() +} + +// GetFilePickerFilterInputView returns the filter input view +func (p *PromptPane) GetFilePickerFilterInputView() string { + return p.filePicker.GetFilterInputView() +} + +// IsFilePickerFilterInputVisible returns true if the filter input should be shown +func (p *PromptPane) IsFilePickerFilterInputVisible() bool { + return p.filePicker.IsFilterInputVisible() +} + +// GetInputContainerStyle returns the input container style for wrapping the file picker +func (p *PromptPane) GetInputContainerStyle() lipgloss.Style { + return p.inputContainer +} + +// GetInfoLabelStyle returns the info label style for rendering reminders +func (p *PromptPane) GetInfoLabelStyle() lipgloss.Style { + return infoLabel +} + +// GetInfoBlockStyle returns the info block style for rendering reminders +func (p *PromptPane) GetInfoBlockStyle() lipgloss.Style { + return infoBlockStyle +} + +// GetFilePickerPreviewView returns the file picker preview view for rendering in main view +func (p *PromptPane) GetFilePickerPreviewView(height int) string { + return p.filePicker.GetPreviewView(height) +} + +// GetFilePickerIsContextMode returns whether the file picker is in context mode +func (p *PromptPane) GetFilePickerIsContextMode() bool { + return p.filePicker.IsContextMode +} diff --git a/util/const.go b/util/const.go new file mode 100644 index 0000000..c3820af --- /dev/null +++ b/util/const.go @@ -0,0 +1,18 @@ +package util + +import "time" + +// Need manual review, should this be merged into defaults.go? +const ( + MaxReadFileSize = 1024 * 1024 // 1MB - max file size for reading + FilePickerDebounce = 400 * time.Millisecond // Debounce delay for file picker search +) + +// File picker constants +const ( + MaxPreviewFileSize = 1024 * 1024 // 1MB - max file size for text preview, assume to be the same as read file size + MaxPreviewContentSize = 10000 // Max characters to show in preview + Utf8CheckBufferSize = 1024 // Bytes to read for UTF-8 validity check + ErrorDisplayDuration = 2 * time.Second // Duration to show error messages + MaxSearchResults = 200 // Maximum number of search results to return +) diff --git a/util/dimensions.go b/util/dimensions.go index 637aa10..85555eb 100644 --- a/util/dimensions.go +++ b/util/dimensions.go @@ -88,7 +88,9 @@ func CalcPromptPaneSize(tw, th int, mode ViewMode) (w, h int) { paneHeight := oneThird(th) return tw - PromptPanePadding, paneHeight case FilePickerMode: - paneHeight := oneThird(th) + // Full screen file picker: use almost full terminal height + // Reserve space for borders, info block, and padding + paneHeight := th - 4 return tw - PromptPanePadding, paneHeight } @@ -124,7 +126,9 @@ func CalcChatPaneSize(tw, th int, mode ViewMode) (w, h int) { paneHeight = twoThirds(th) - EditModeUIElementsSum - 1 paneWidth = tw - DefaultElementsPadding case FilePickerMode: - paneHeight = twoThirds(th) - EditModeUIElementsSum - 2 + // Full screen file picker: use almost full terminal height + // Reserve space for borders, info block, and padding + paneHeight = th - 4 paneWidth = tw - DefaultElementsPadding } diff --git a/util/file-reader.go b/util/file-reader.go new file mode 100644 index 0000000..f93ac6f --- /dev/null +++ b/util/file-reader.go @@ -0,0 +1,288 @@ +package util + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + "unicode/utf8" +) + +const maxReadFileSize = 5 * 1024 * 1024 // 5MB + +// WalkDirectoryFunc is a callback function for WalkDirectory +// Return true to include the file in results, false to skip +type WalkDirectoryFunc func(path string, info fs.DirEntry, relPath string, depth int) bool + +// WalkDirectory recursively walks a directory tree and calls the filter function for each entry +// maxDepth limits how deep the walk goes (0 = current dir only, 1 = one level deep, etc.) +// The filterFunc is called for each file/directory with: +// - path: absolute file path +// - info: the directory entry +// - relPath: relative path from root +// - depth: depth level (0 = root) +// +// Returns all paths where filterFunc returned true, or an error if walking fails +func WalkDirectory(rootPath string, maxDepth int, filterFunc WalkDirectoryFunc) ([]string, error) { + var results []string + + err := filepath.WalkDir(rootPath, func(filePath string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return nil // Skip files with errors + } + + // Skip the root directory itself + if filePath == rootPath { + return nil + } + + // Calculate relative path and depth + relPath, relErr := filepath.Rel(rootPath, filePath) + if relErr != nil { + return nil + } + depth := strings.Count(relPath, string(filepath.Separator)) + + if depth > maxDepth { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + + // Skip hidden files and directories + baseName := filepath.Base(filePath) + if strings.HasPrefix(baseName, ".") { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + + // Call the filter function + if filterFunc(filePath, d, relPath, depth) { + results = append(results, filePath) + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to walk directory: %w", err) + } + + return results, nil +} + +// ReadFileContent reads the content of a file and returns it as a string +// Returns an error if the file is binary, too large, or cannot be read +func ReadFileContent(path string) (string, error) { + // Check file size before reading + info, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("failed to get file info: %w", err) + } + + // Reject files larger than maxReadFileSize + if info.Size() > maxReadFileSize { + return "", fmt.Errorf("file too large (%d bytes, max %d bytes)", info.Size(), maxReadFileSize) + } + + content, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("failed to read file: %w", err) + } + + // Check if the content is valid UTF-8 (text file) + if !utf8.Valid(content) { + return "", fmt.Errorf("file appears to be binary") + } + + return string(content), nil +} + +// ReadFolderContents recursively reads all non-media files in a folder +// Returns a map of file paths to their contents +func ReadFolderContents(path string, maxDepth int) (map[string]string, []string, error) { + contents := make(map[string]string) + var filePaths []string + + err := filepath.WalkDir(path, func(filePath string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip the root directory itself + if filePath == path { + return nil + } + + // Calculate depth + relPath, err := filepath.Rel(path, filePath) + if err != nil { + return err + } + depth := strings.Count(relPath, string(filepath.Separator)) + + if depth > maxDepth { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + + // Skip hidden files and directories + baseName := filepath.Base(filePath) + if strings.HasPrefix(baseName, ".") { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + + // Skip directories (we'll process files) + if d.IsDir() { + return nil + } + + // Skip media files + ext := strings.ToLower(filepath.Ext(filePath)) + if slices.Contains(MediaExtensions, ext) { + return nil + } + + // Read file content + content, err := ReadFileContent(filePath) + if err != nil { + // Log error but continue with other files + Slog.Warn("failed to read file in folder", "path", filePath, "error", err.Error()) + return nil + } + + contents[filePath] = content + filePaths = append(filePaths, filePath) + + return nil + }) + + if err != nil { + return contents, filePaths, fmt.Errorf("failed to read folder: %w", err) + } + + return contents, filePaths, nil +} + +// IsMediaFile checks if a file is a media file based on its extension +func IsMediaFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + return slices.Contains(MediaExtensions, ext) +} + +// GetFileSize returns the size of a file in bytes +func GetFileSize(path string) (int64, error) { + info, err := os.Stat(path) + if err != nil { + return 0, fmt.Errorf("failed to get file size: %w", err) + } + return info.Size(), nil +} + +// FormatFileContent formats file content for inclusion in the message +// Adds a header with the file path and wraps code files in markdown code blocks +func FormatFileContent(path, content string) string { + ext := strings.ToLower(filepath.Ext(path)) + var formatted strings.Builder + + // Add file header + formatted.WriteString(fmt.Sprintf("--- File: %s ---\n", path)) + + // Check if it's a code file + if slices.Contains(CodeExtensions, ext) { + // Get language for code block + lang := strings.TrimPrefix(ext, ".") + formatted.WriteString(fmt.Sprintf("```%s\n", lang)) + formatted.WriteString(content) + formatted.WriteString("\n```\n") + } else { + // Plain text file + formatted.WriteString(content) + } + + formatted.WriteString("\n---\n") + + return formatted.String() +} + +// FormatFolderContents formats multiple file contents for inclusion in the message +func FormatFolderContents(contents map[string]string, filePaths []string) string { + var formatted strings.Builder + + formatted.WriteString(fmt.Sprintf("--- Folder: %d files ---\n", len(filePaths))) + + for _, path := range filePaths { + content, ok := contents[path] + if ok { + formatted.WriteString(FormatFileContent(path, content)) + } + } + + return formatted.String() +} + +// CountFilesInFolder counts the number of non-media files in a folder +func CountFilesInFolder(path string, maxDepth int) (int, error) { + // Use WalkDirectory to count non-media, non-directory files + results, err := WalkDirectory(path, maxDepth, func(filePath string, d fs.DirEntry, relPath string, depth int) bool { + // Skip directories + if d.IsDir() { + return false + } + // Skip media files + ext := strings.ToLower(filepath.Ext(filePath)) + if slices.Contains(MediaExtensions, ext) { + return false + } + return true + }) + + if err != nil { + return 0, fmt.Errorf("failed to count files in folder: %w", err) + } + + return len(results), nil +} + +// ListFolderEntries returns a list of files and folders in a directory (non-recursive) +// Returns a formatted string with file/folder names and their types +func ListFolderEntries(path string) (string, error) { + entries, err := os.ReadDir(path) + if err != nil { + return "", fmt.Errorf("failed to read directory: %w", err) + } + + var result strings.Builder + result.WriteString(fmt.Sprintf("--- Folder: %s ---\n", filepath.Base(path))) + + for _, entry := range entries { + // Skip hidden files and directories + if strings.HasPrefix(entry.Name(), ".") { + continue + } + + if entry.IsDir() { + result.WriteString(fmt.Sprintf("📁 %s/\n", entry.Name())) + } else { + // Skip media files + ext := strings.ToLower(filepath.Ext(entry.Name())) + if slices.Contains(MediaExtensions, ext) { + continue + } + result.WriteString(fmt.Sprintf("📄 %s\n", entry.Name())) + } + } + + result.WriteString("---\n") + return result.String(), nil +} diff --git a/util/formatter.go b/util/formatter.go index 800d2af..2fb90ee 100644 --- a/util/formatter.go +++ b/util/formatter.go @@ -6,6 +6,7 @@ import ( "regexp" "slices" "strings" + "unicode/utf8" "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss" @@ -18,6 +19,7 @@ func GetMessagesAsPrettyString( colors SchemeColors, isQuickChat bool, settings Settings, + showContextContent bool, ) string { var messages string @@ -27,7 +29,7 @@ func GetMessagesAsPrettyString( switch message.Role { case "user": - messageToUse = RenderUserMessage(message, w, colors, false) + messageToUse = RenderUserMessage(message, w, colors, false, showContextContent) case "assistant": messageToUse = RenderBotMessage(message, w, colors, false, settings) case "tool": @@ -50,7 +52,7 @@ func GetMessagesAsPrettyString( return messages } -func GetVisualModeView(msgsToRender []LocalStoreMessage, w int, colors SchemeColors, settings Settings) string { +func GetVisualModeView(msgsToRender []LocalStoreMessage, w int, colors SchemeColors, settings Settings, showContextContent bool) string { var messages string w = w - TextSelectorMaxWidthCorrection for _, message := range msgsToRender { @@ -59,7 +61,7 @@ func GetVisualModeView(msgsToRender []LocalStoreMessage, w int, colors SchemeCol switch message.Role { case "user": - messageToUse = RenderUserMessage(message, w, colors, true) + messageToUse = RenderUserMessage(message, w, colors, true, showContextContent) case "assistant": messageToUse = RenderBotMessage(message, w, colors, true, settings) case "tool": @@ -77,7 +79,7 @@ func GetVisualModeView(msgsToRender []LocalStoreMessage, w int, colors SchemeCol return messages } -func RenderUserMessage(userMessage LocalStoreMessage, width int, colors SchemeColors, isVisualMode bool) string { +func RenderUserMessage(userMessage LocalStoreMessage, width int, colors SchemeColors, isVisualMode bool, showContextContent bool) string { renderer, _ := glamour.NewTermRenderer( glamour.WithPreservedNewLines(), glamour.WithWordWrap(width-WordWrapDelta), @@ -101,6 +103,40 @@ func RenderUserMessage(userMessage LocalStoreMessage, width int, colors SchemeCo msg += attachments } + // Render context chips + if len(userMessage.ContextChips) != 0 { + if showContextContent && userMessage.ContextContent != "" { + // Show folder entries for folders, full content for files + msg += "\n *Context Content:* \n" + for _, chip := range userMessage.ContextChips { + if chip.IsFolder { + // Show NON-RECURSIVE folder entries list + if chip.FolderEntries != "" { + msg += chip.FolderEntries + } else { + // Fallback to full content if folder entries not available + msg += fmt.Sprintf("--- Folder: %s ---\n", chip.Name) + } + } + } + // Add file contents (non-folder chips) from FileContents + if userMessage.FileContents != "" { + msg += userMessage.FileContents + } + } else { + // Show labels only + contextLabels := "\n *Context:* \n" + for _, chip := range userMessage.ContextChips { + if chip.IsFolder { + contextLabels += fmt.Sprintf("# [📁 %s] (%d files)\n", chip.Name, chip.FileCount) + } else { + contextLabels += fmt.Sprintf("# [📄 %s]\n", chip.Name) + } + } + msg += contextLabels + } + } + userMsg, _ := renderer.Render(msg) output := strings.TrimSpace(userMsg) return lipgloss.NewStyle(). @@ -406,3 +442,43 @@ func removeZWJEmojis(input string) string { return sb.String() } + +// FormatSize formats a file size in human-readable format +func FormatSize(size int64) string { + const unit = 1024 + if size < unit { + return fmt.Sprintf("%d B", size) + } + div, exp := int64(unit), 0 + for n := size / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp]) +} + +// ansiRegexForTruncate is used for stripping ANSI codes when truncating lines +var ansiRegexForTruncate = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// TruncateLineWithANSI truncates a line to max visible characters while preserving ANSI codes +func TruncateLineWithANSI(line string, maxLen int) string { + // Remove ANSI codes temporarily to count visible characters + cleanLine := ansiRegexForTruncate.ReplaceAllString(line, "") + + // If clean line is already short enough, return original + if utf8.RuneCountInString(cleanLine) <= maxLen { + return line + } + + // Truncate the clean line and add ellipsis + runes := []rune(cleanLine) + if len(runes) > maxLen { + runes = runes[:maxLen-3] // Reserve 3 chars for "..." + } + truncatedClean := string(runes) + "..." + + // Now we need to rebuild the line with ANSI codes + // This is complex, so for simplicity, we'll just return the truncated clean line + // A more sophisticated approach would preserve ANSI codes at the beginning + return truncatedClean +} diff --git a/util/shared-events.go b/util/shared-events.go index dd75bc5..2b0be58 100644 --- a/util/shared-events.go +++ b/util/shared-events.go @@ -121,8 +121,9 @@ func SendProcessingStateChangedMsg(processingState ProcessingState) tea.Cmd { } type PromptReady struct { - Prompt string - Attachments []Attachment + Prompt string + Attachments []Attachment + ContextChips []FileContextChip } func SendPromptReadyMsg(prompt string, attachments []Attachment) tea.Cmd { @@ -134,6 +135,25 @@ func SendPromptReadyMsg(prompt string, attachments []Attachment) tea.Cmd { } } +func SendPromptReadyWithContextMsg(prompt string, attachments []Attachment, contextChips []FileContextChip) tea.Cmd { + return func() tea.Msg { + return PromptReady{ + Prompt: prompt, + Attachments: attachments, + ContextChips: contextChips, + } + } +} + +type ContextChipsProcessed struct { + Prompt string + Attachments []Attachment + ContextContent string + Errors []string // Errors that occurred during processing + ContextChips []FileContextChip // Updated chips with FolderEntries populated + FileContents string // Contents of non-folder chips only (for display when expanded) +} + type AsyncDependencyReady struct { Dependency AsyncDependency } @@ -186,12 +206,20 @@ func SendCopyAllMsgs() tea.Msg { } type ViewModeChanged struct { - Mode ViewMode + Mode ViewMode + IsContextMode bool // indicates if file picker is in context mode } func SendViewModeChangedMsg(mode ViewMode) tea.Cmd { return func() tea.Msg { - return ViewModeChanged{Mode: mode} + return ViewModeChanged{Mode: mode, IsContextMode: false} + } +} + +// SendViewModeChangedWithContextMsg sends ViewModeChanged with context mode flag +func SendViewModeChangedWithContextMsg(mode ViewMode, isContextMode bool) tea.Cmd { + return func() tea.Msg { + return ViewModeChanged{Mode: mode, IsContextMode: isContextMode} } } @@ -249,3 +277,9 @@ func AddNewSession(isTemporary bool) tea.Cmd { } } } + +type ToggleContextContent struct{} + +func SendToggleContextContentMsg() tea.Msg { + return ToggleContextContent{} +} diff --git a/util/shared-types.go b/util/shared-types.go index a35a7fc..d172c15 100644 --- a/util/shared-types.go +++ b/util/shared-types.go @@ -1,6 +1,9 @@ package util -import "context" +import ( + "context" + "strings" +) type Settings struct { ID int @@ -16,12 +19,15 @@ type Settings struct { } type LocalStoreMessage struct { - Model string `json:"model"` - Role string `json:"role"` - Content string `json:"content"` - Resoning string `json:"reasoning"` - Attachments []Attachment `json:"attachments"` - ToolCalls []ToolCall `json:"tool_calls"` + Model string `json:"model"` + Role string `json:"role"` + Content string `json:"content"` + Resoning string `json:"reasoning"` + Attachments []Attachment `json:"attachments"` + ToolCalls []ToolCall `json:"tool_calls"` + ContextChips []FileContextChip `json:"context_chips"` + ContextContent string `json:"context_content"` + FileContents string `json:"file_contents"` // Contents of non-folder chips only (for display when expanded) } type Attachment struct { @@ -112,3 +118,74 @@ func WriteToResponseChannel(ctx context.Context, ch chan<- ProcessApiCompletionR Slog.Debug("Context cancelled, skipping write to channel", "msg_id", msg.ID) } } + +// FileContextChip represents a selected file/folder context in the prompt +type FileContextChip struct { + Path string `json:"path"` + Name string `json:"name"` + IsFolder bool `json:"is_folder"` + Size int64 `json:"size"` + FileCount int `json:"file_count"` + FolderEntries string `json:"folder_entries"` // Non-recursive list of files/folders in folder +} + +// MediaExtensions contains file extensions to exclude from context import +var MediaExtensions = []string{ + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", + ".mp4", ".avi", ".mov", ".mkv", ".webm", + ".mp3", ".wav", ".ogg", ".flac", + ".pdf", ".zip", ".tar", ".gz", ".7z", ".rar", +} + +// CodeExtensions contains file extensions that should be wrapped in code blocks +// when displaying content. +var CodeExtensions = []string{ + ".go", ".js", ".ts", ".py", ".java", ".c", ".cpp", ".h", ".hpp", + ".rs", ".rb", ".php", ".swift", ".kt", ".scala", ".cs", ".sh", + ".bash", ".zsh", ".fish", ".ps1", ".bat", ".cmd", ".sql", ".html", + ".css", ".scss", ".sass", ".less", ".json", ".xml", ".yaml", ".yml", + ".toml", ".ini", ".cfg", ".conf", ".md", ".markdown", +} + +// TextExtensions contains common text file extensions used for file picker hinting. +var TextExtensions = []string{ + ".txt", ".text", ".md", ".markdown", ".mdown", ".mkd", ".mkdn", + ".rst", ".rest", ".adoc", ".asciidoc", ".org", ".tex", ".latex", + ".log", ".out", ".err", + ".csv", ".tsv", ".tab", ".psv", + ".json", ".jsonl", ".ndjson", + ".xml", ".xsd", ".xsl", ".xslt", + ".yaml", ".yml", + ".toml", ".ini", ".cfg", ".conf", ".config", + ".env", ".properties", ".prop", ".props", + ".lst", ".list", ".textile", + ".srt", ".vtt", + ".ics", ".vcf", + ".sql", ".psql", + ".http", ".rest", + ".diff", ".patch", +} + +// Quick helper to check union between those code and text files. +func IsTextOrCodeExtension(ext string) bool { + extLower := strings.ToLower(ext) + for _, codeExt := range CodeExtensions { + if extLower == codeExt { + return true + } + } + for _, textExt := range TextExtensions { + if extLower == textExt { + return true + } + } + return false +} + +// SearchResult represents a file found during recursive search in file picker +type SearchResult struct { + Path string + RelPath string // Relative to current directory + IsDir bool + Size int64 +} diff --git a/views/main-view.go b/views/main-view.go index 943cd08..354c0c9 100644 --- a/views/main-view.go +++ b/views/main-view.go @@ -8,6 +8,7 @@ import ( "os" "runtime" "slices" + "strings" "time" "github.com/charmbracelet/bubbles/key" @@ -80,13 +81,16 @@ var defaultKeyMap = keyMap{ } type MainView struct { - viewReady bool - controlsLocked bool - focused util.Pane - viewMode util.ViewMode - error util.ErrorEvent - currentSessionID string - keys keyMap + viewReady bool + controlsLocked bool + focused util.Pane + viewMode util.ViewMode + isContextMode bool // tracks if file picker is in context mode + error util.ErrorEvent + currentSessionID string + keys keyMap + showContextContent bool + pendingContextChips []util.FileContextChip chatPane panes.ChatPane promptPane panes.PromptPane @@ -151,6 +155,7 @@ func NewMainView(db *sql.DB, ctx context.Context) MainView { return MainView{ keys: defaultKeyMap, viewMode: util.NormalMode, + isContextMode: false, // Default to false focused: util.PromptPane, currentSessionID: "", sessionOrchestrator: orchestrator, @@ -164,6 +169,8 @@ func NewMainView(db *sql.DB, ctx context.Context) MainView { flags: *flags, context: ctx, initialPrompt: flags.InitialPrompt, + showContextContent: false, + pendingContextChips: []util.FileContextChip{}, } } @@ -220,6 +227,7 @@ func (m MainView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case util.ViewModeChanged: m.viewMode = msg.Mode + m.isContextMode = msg.IsContextMode case util.SwitchToPaneMsg: if util.IsFocusAllowed(m.viewMode, msg.Target, m.terminalWidth) { @@ -338,14 +346,16 @@ func (m MainView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { loadedAttachments := []util.Attachment{} if len(msg.Attachments) != 0 { - util.Slog.Debug("preparing attachments") + util.Slog.Debug("preparing attachments", "count", len(msg.Attachments)) - for _, attachment := range msg.Attachments { + for i, attachment := range msg.Attachments { + util.Slog.Debug("Converting attachment to base64", "index", i, "path", attachment.Path) b64, err := m.fileToBase64(attachment.Path) if err != nil { util.Slog.Error("failed to convert attachment to base64", "error", err.Error()) return m, util.MakeErrorMsg(err.Error()) } + util.Slog.Debug("Attachment converted to base64", "index", i, "base64_length", len(b64)) t := util.Attachment{ Path: attachment.Path, @@ -354,14 +364,27 @@ func (m MainView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } loadedAttachments = append(loadedAttachments, t) } + util.Slog.Debug("All attachments prepared", "total", len(loadedAttachments)) } + // Process context chips asynchronously + if len(msg.ContextChips) > 0 { + // Store context chips for later use + m.pendingContextChips = msg.ContextChips + util.Slog.Debug("dispatching async context chips processing", "count", len(msg.ContextChips)) + // Capture maxDepth at command creation time to avoid race condition + maxDepth := m.config.GetContextMaxDepth() + return m, m.makeProcessContextChipsCmd(msg.Prompt, loadedAttachments, msg.ContextChips, maxDepth) + } + + // No context chips, proceed directly m.sessionOrchestrator.ArrayOfMessages = append( m.sessionOrchestrator.ArrayOfMessages, util.LocalStoreMessage{ - Role: "user", - Content: msg.Prompt, - Attachments: loadedAttachments, + Role: "user", + Content: msg.Prompt, + Attachments: loadedAttachments, + ContextChips: msg.ContextChips, }) m.viewMode = util.NormalMode m.controlsLocked = true @@ -372,6 +395,55 @@ func (m MainView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { util.SendViewModeChangedMsg(m.viewMode), m.chatPane.DisplayCompletion(m.processingCtx, &m.sessionOrchestrator)) + case util.ContextChipsProcessed: + // Context chips have been processed asynchronously + // Store formatted context content separately for display + contextContent := msg.ContextContent + if contextContent != "" { + contextContent = "\n\n" + contextContent + } + + m.sessionOrchestrator.ArrayOfMessages = append( + m.sessionOrchestrator.ArrayOfMessages, + util.LocalStoreMessage{ + Role: "user", + Content: msg.Prompt, // Don't append context content here for display + Attachments: msg.Attachments, + ContextChips: msg.ContextChips, // Use updated chips with FolderEntries populated + ContextContent: contextContent, // Store formatted content separately + FileContents: msg.FileContents, // Store file contents for display when expanded + }) + // Store FileContents for display when expanded + m.pendingContextChips = msg.ContextChips + // Clear pending context chips + m.pendingContextChips = []util.FileContextChip{} + m.viewMode = util.NormalMode + m.controlsLocked = true + + m.setProcessingContext() + + // Build the sequence of commands + sequence := []tea.Cmd{ + util.SendProcessingStateChangedMsg(util.ProcessingChunks), + util.SendViewModeChangedMsg(m.viewMode), + m.chatPane.DisplayCompletion(m.processingCtx, &m.sessionOrchestrator), + } + + // Show notification if there were errors during processing (non-blocking) + if len(msg.Errors) > 0 { + errorMsg := fmt.Sprintf("Some context files failed to load:\n%s", strings.Join(msg.Errors, "\n")) + util.Slog.Warn("some context files failed to load", "errors", errorMsg) + } + + return m, tea.Sequence(sequence...) + + case util.ToggleContextContent: + m.showContextContent = !m.showContextContent + // Also update chat pane state + m.chatPane, cmd = m.chatPane.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + case tea.MouseMsg: targetPane := m.focused @@ -380,8 +452,10 @@ func (m MainView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { + util.Slog.Debug("MainView.MouseMsg: checking zones", "viewMode", m.viewMode, "currentFocused", m.focused) switch { case zone.Get("chat_pane").InBounds(msg): + util.Slog.Debug("MainView.MouseMsg: chat_pane clicked", "viewMode", m.viewMode) targetPane = util.ChatPane case zone.Get("prompt_pane").InBounds(msg): targetPane = util.PromptPane @@ -392,6 +466,7 @@ func (m MainView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if targetPane != m.focused { + util.Slog.Debug("MainView.MouseMsg: focus change requested", "from", m.focused, "to", targetPane, "viewMode", m.viewMode) m.handleFocusChange(targetPane, true) return m, nil } @@ -530,20 +605,100 @@ func (m MainView) View() string { mainView = m.chatPane.DisplayError(m.error.Message) } - secondaryScreen := "" + // In FilePickerMode, render file picker in place of chat pane + if m.viewMode == util.FilePickerMode { + // In context mode, use view without filter (filter is shown above preview) + if m.promptPane.GetFilePickerIsContextMode() { + mainView = m.promptPane.GetFilePickerViewWithoutFilter() + } else { + mainView = m.promptPane.GetFilePickerView() + } + } + + // Conditionally render windowViews based on view mode if m.viewMode == util.NormalMode { - secondaryScreen = settingsAndSessionPanes + windowViews = lipgloss.NewStyle(). + Align(lipgloss.Right, lipgloss.Right). + Render( + lipgloss.JoinHorizontal( + lipgloss.Top, + mainView, + settingsAndSessionPanes, + ), + ) + } else if m.viewMode == util.FilePickerMode { + // In FilePickerMode, render file picker with optional preview pane + if m.promptPane.GetFilePickerIsContextMode() { + // For context mode, show file picker with preview pane on the right + previewHeight := m.terminalHeight - 10 // Reserve space for borders and reminders + previewView := m.promptPane.GetFilePickerPreviewView(previewHeight) + + if previewView != "" { + // Show file picker and preview side by side + // Filter input is shown above the preview only when visible + filterInputView := m.promptPane.GetFilePickerFilterInputView() + var previewWithFilter string + if filterInputView != "" { + previewWithFilter = lipgloss.JoinVertical( + lipgloss.Left, + filterInputView, + previewView, + ) + } else { + previewWithFilter = previewView + } + // Create a vertical border between file picker and preview + colors := m.config.ColorScheme.GetColors() + borderStyle := lipgloss.NewStyle(). + Foreground(colors.NormalTabBorderColor) + border := borderStyle.Render(" - ") + windowViews = lipgloss.NewStyle(). + Align(lipgloss.Right, lipgloss.Right). + Render( + lipgloss.JoinHorizontal( + lipgloss.Top, + mainView, + border, + previewWithFilter, + ), + ) + } else { + // No preview, show file picker in full width + windowViews = lipgloss.NewStyle(). + Align(lipgloss.Right, lipgloss.Right). + Render(mainView) + } + } else { + // For media mode, show file picker in full width + windowViews = lipgloss.NewStyle(). + Align(lipgloss.Right, lipgloss.Right). + Render(mainView) + } + } else { + // In TextEditMode, ZenMode, etc. + // Only render mainView without secondary screen + windowViews = lipgloss.NewStyle(). + Align(lipgloss.Right, lipgloss.Right). + Render(mainView) } - windowViews = lipgloss.NewStyle(). - Align(lipgloss.Right, lipgloss.Right). - Render( - lipgloss.JoinHorizontal( - lipgloss.Top, - mainView, - secondaryScreen, - ), - ) + util.Slog.Debug("MainView.View: rendering", "viewMode", m.viewMode) + + // Wrap file picker with input container style to restore border + if m.viewMode == util.FilePickerMode { + inputContainer := m.promptPane.GetInputContainerStyle() + infoLabel := m.promptPane.GetInfoLabelStyle() + infoBlockStyle := m.promptPane.GetInfoBlockStyle() + + // Add reminder under file picker + reminderContent := infoLabel.Render("Use ctrl+a to attach an image • @ to add file context • Ctrl+/ to filter • Enter to select file") + + return zone.Scan(lipgloss.JoinVertical( + lipgloss.Left, + inputContainer.Render(windowViews), + infoBlockStyle.Render(reminderContent), + )) + } promptView := m.promptPane.View() @@ -600,6 +755,65 @@ func mapAttachmentType(attachmentType string) string { return "" } +func (m MainView) makeProcessContextChipsCmd(prompt string, attachments []util.Attachment, chips []util.FileContextChip, maxDepth int) tea.Cmd { + return func() tea.Msg { + var contextContent strings.Builder + var fileContents strings.Builder // For non-folder chips only + var errors []string + updatedChips := make([]util.FileContextChip, len(chips)) + + for i, chip := range chips { + updatedChips[i] = chip // Copy the chip + if chip.IsFolder { + // Read folder contents + contents, filePaths, err := util.ReadFolderContents(chip.Path, maxDepth) + if err != nil { + errMsg := fmt.Sprintf("Failed to read folder %s: %s", chip.Path, err.Error()) + util.Slog.Error("failed to read folder", "path", chip.Path, "error", err.Error()) + errors = append(errors, errMsg) + continue + } + + // Format folder contents + formatted := util.FormatFolderContents(contents, filePaths) + contextContent.WriteString(formatted) + + // Get non-recursive folder entries list for display + folderEntries, err := util.ListFolderEntries(chip.Path) + if err != nil { + util.Slog.Error("failed to list folder entries", "path", chip.Path, "error", err.Error()) + folderEntries = "" + } + updatedChips[i].FolderEntries = folderEntries + } else { + // Read single file + util.Slog.Debug("reading file context", "path", chip.Path) + content, err := util.ReadFileContent(chip.Path) + if err != nil { + errMsg := fmt.Sprintf("Failed to read file %s: %s", chip.Path, err.Error()) + util.Slog.Error("failed to read file", "path", chip.Path, "error", err.Error()) + errors = append(errors, errMsg) + continue + } + + // Format file content + formatted := util.FormatFileContent(chip.Path, content) + contextContent.WriteString(formatted) + fileContents.WriteString(formatted) // Also add to fileContents for display + } + } + + return util.ContextChipsProcessed{ + Prompt: prompt, + Attachments: attachments, + ContextContent: contextContent.String(), + Errors: errors, + ContextChips: updatedChips, + FileContents: fileContents.String(), + } + } +} + // TODO: use event to lock/unlock allowFocusChange flag? func (m MainView) isFocusChangeAllowed(isMouseEvent bool) bool { if !m.promptPane.AllowFocusChange(isMouseEvent) ||