From 34b49a68b36645ec0358883b6ebf01af68612c4c Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Tue, 10 Feb 2026 08:59:15 +0700 Subject: [PATCH 01/25] feat: added context insertion pane (#20) --- components/context-picker.go | 236 +++++++++++++++++++++++++++++++ config/config-handler.go | 2 + config/config.json | 4 +- panes/prompt-pane.go | 115 ++++++++++++++-- util/file-reader.go | 260 +++++++++++++++++++++++++++++++++++ util/shared-events.go | 16 ++- util/shared-types.go | 35 +++++ views/main-view.go | 48 ++++++- 8 files changed, 704 insertions(+), 12 deletions(-) create mode 100644 components/context-picker.go create mode 100644 util/file-reader.go diff --git a/components/context-picker.go b/components/context-picker.go new file mode 100644 index 0000000..5f04f24 --- /dev/null +++ b/components/context-picker.go @@ -0,0 +1,236 @@ +package components + +import ( + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/BalanceBalls/nekot/util" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + zone "github.com/lrstanley/bubblezone" +) + +type ContextPicker struct { + list list.Model + SelectedPath string + PrevView util.ViewMode + PrevInput string + quitting bool + baseDir string + showIcons bool + maxDepth int +} + +var contextPickerTips = "/ filter • enter select • esc cancel" + +var contextPickerListItemSpan = lipgloss.NewStyle(). + PaddingLeft(util.ListItemPaddingLeft) + +var contextPickerListItemSpanSelected = lipgloss.NewStyle(). + PaddingLeft(util.ListItemPaddingLeft) + +type ContextPickerItem struct { + Path string + Name string + IsFolder bool + Size int64 + Icon string +} + +func (i ContextPickerItem) FilterValue() string { return zone.Mark(i.Path, i.Name) } + +type contextPickerItemDelegate struct{} + +func (d contextPickerItemDelegate) Height() int { return 1 } +func (d contextPickerItemDelegate) Spacing() int { return 0 } +func (d contextPickerItemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } +func (d contextPickerItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(ContextPickerItem) + if !ok { + return + } + + icon := "" + if i.IsFolder { + icon = "📁 " + } else { + icon = "📄 " + } + + str := fmt.Sprintf("%s%s", icon, i.Name) + str = util.TrimListItem(str, m.Width()) + str = zone.Mark(i.Path, str) + + fn := contextPickerListItemSpan.Render + if index == m.Index() { + fn = func(s ...string) string { + row := "> " + strings.Join(s, " ") + return contextPickerListItemSpanSelected.Render(row) + } + } + + fmt.Fprint(w, fn(str)) +} + +func (l *ContextPicker) View() string { + if l.list.FilterState() == list.Filtering { + l.list.SetShowStatusBar(false) + } else { + l.list.SetShowStatusBar(true) + } + return lipgloss.JoinVertical( + lipgloss.Left, + l.list.View(), + util.HelpStyle.Render(contextPickerTips)) +} + +func (l *ContextPicker) GetSelectedItem() (ContextPickerItem, bool) { + item, ok := l.list.SelectedItem().(ContextPickerItem) + return item, ok +} + +func (l ContextPicker) VisibleItems() []list.Item { + return l.list.VisibleItems() +} + +func (l ContextPicker) IsFiltering() bool { + return l.list.SettingFilter() +} + +func (l *ContextPicker) Update(msg tea.Msg) (ContextPicker, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + + case tea.KeyMsg: + switch msg.String() { + case "esc", "q": + l.quitting = true + return *l, util.SendViewModeChangedMsg(l.PrevView) + case "enter": + if item, ok := l.GetSelectedItem(); ok { + l.SelectedPath = item.Path + l.quitting = true + return *l, util.SendViewModeChangedMsg(l.PrevView) + } + } + + case tea.MouseMsg: + if msg.Button == tea.MouseButtonWheelUp { + l.list.CursorUp() + return *l, nil + } + + if msg.Button == tea.MouseButtonWheelDown { + l.list.CursorDown() + return *l, nil + } + } + + l.list, cmd = l.list.Update(msg) + return *l, cmd +} + +func NewContextPicker(prevView util.ViewMode, prevInput string, colors util.SchemeColors, showIcons bool, maxDepth int) ContextPicker { + baseDir, err := os.Getwd() + if err != nil { + util.Slog.Error("failed to get current directory", "error", err.Error()) + baseDir, _ = os.UserHomeDir() + } + + // Create a temporary ContextPicker to call scanDirectory + tempPicker := ContextPicker{} + items := tempPicker.scanDirectory(baseDir, 0, maxDepth) + + h := 20 // Default height + l := list.New(items, contextPickerItemDelegate{}, 80, h) + + l.SetStatusBarItemName("item", "items") + l.SetShowTitle(false) + l.SetShowHelp(false) + l.SetFilteringEnabled(true) + l.DisableQuitKeybindings() + + l.Paginator.ActiveDot = lipgloss.NewStyle().Foreground(colors.HighlightColor).Render(util.ActiveDot) + l.Paginator.InactiveDot = lipgloss.NewStyle().Foreground(colors.DefaultTextColor).Render(util.InactiveDot) + contextPickerListItemSpan = contextPickerListItemSpan.Foreground(colors.DefaultTextColor) + contextPickerListItemSpanSelected = contextPickerListItemSpanSelected.Foreground(colors.AccentColor) + l.FilterInput.PromptStyle = l.FilterInput.PromptStyle.Foreground(colors.ActiveTabBorderColor).PaddingBottom(0).Margin(0) + l.FilterInput.Cursor.Style = l.FilterInput.Cursor.Style.Foreground(colors.NormalTabBorderColor) + + return ContextPicker{ + list: l, + PrevView: prevView, + PrevInput: prevInput, + baseDir: baseDir, + showIcons: showIcons, + maxDepth: maxDepth, + } +} + +func (l *ContextPicker) scanDirectory(dir string, currentDepth, maxDepth int) []list.Item { + var items []list.Item + + if currentDepth > maxDepth { + return items + } + + entries, err := os.ReadDir(dir) + if err != nil { + util.Slog.Error("failed to read directory", "path", dir, "error", err.Error()) + return items + } + + for _, entry := range entries { + name := entry.Name() + + // Skip hidden files + if strings.HasPrefix(name, ".") { + continue + } + + fullPath := filepath.Join(dir, name) + + info, err := entry.Info() + if err != nil { + continue + } + + if entry.IsDir() { + items = append(items, ContextPickerItem{ + Path: fullPath, + Name: name, + IsFolder: true, + Size: 0, + Icon: "📁", + }) + } else { + // Check if it's a media file + ext := strings.ToLower(filepath.Ext(name)) + if slices.Contains(util.MediaExtensions, ext) { + continue + } + + items = append(items, ContextPickerItem{ + Path: fullPath, + Name: name, + IsFolder: false, + Size: info.Size(), + Icon: "📄", + }) + } + } + + return items +} + +func (l *ContextPicker) SetSize(w, h int) { + if w > 2 && h > 2 { + l.list.SetWidth(w) + l.list.SetHeight(h - 1) // Account for tips row + } +} diff --git a/config/config-handler.go b/config/config-handler.go index 57a7607..1112b12 100644 --- a/config/config-handler.go +++ b/config/config-handler.go @@ -53,6 +53,8 @@ type Config struct { MaxAttachmentSizeMb int `json:"maxAttachmentSizeMb"` IncludeReasoningTokensInContext *bool `json:"includeReasoningTokensInContext"` SessionExportDir string `json:"sessionExportDir"` + ContextMaxDepth int `json:"contextMaxDepth"` + ShowContextIcons bool `json:"showContextIcons"` } type StartupFlags struct { diff --git a/config/config.json b/config/config.json index 2704b59..53f2308 100644 --- a/config/config.json +++ b/config/config.json @@ -6,5 +6,7 @@ "provider": "openai", "maxAttachmentSizeMb": 10, "includeReasoningTokensInContext": true, - "sessionExportDir": "" + "sessionExportDir": "", + "contextMaxDepth": 2, + "showContextIcons": true } diff --git a/panes/prompt-pane.go b/panes/prompt-pane.go index f0e198a..2530c75 100644 --- a/panes/prompt-pane.go +++ b/panes/prompt-pane.go @@ -2,6 +2,7 @@ package panes import ( "context" + "os" "path/filepath" "regexp" "strings" @@ -75,6 +76,7 @@ type PromptPane struct { input textinput.Model textEditor textarea.Model filePicker components.FilePicker + contextPicker components.ContextPicker inputContainer lipgloss.Style inputMode util.PrompInputMode colors util.SchemeColors @@ -82,6 +84,7 @@ type PromptPane struct { pendingInsert string attachments []util.Attachment + contextChips []util.FileContextChip operation util.Operation viewMode util.ViewMode isSessionIdle bool @@ -163,6 +166,7 @@ func (p PromptPane) Update(msg tea.Msg) (PromptPane, tea.Cmd) { cmds = append(cmds, p.processTextInputUpdates(msg)) cmds = append(cmds, p.processFilePickerUpdates(msg)) + cmds = append(cmds, p.processContextPickerUpdates(msg)) p.handlePlaceholder() @@ -251,6 +255,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() @@ -298,7 +303,7 @@ func (p *PromptPane) keyEnter() tea.Cmd { return nil } - if p.viewMode == util.FilePickerMode { + if p.viewMode == util.FilePickerMode || p.viewMode == util.ContextPickerMode { return nil } @@ -329,8 +334,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 +352,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 @@ -430,6 +439,52 @@ func (p *PromptPane) processFilePickerUpdates(msg tea.Msg) tea.Cmd { return tea.Batch(cmds...) } +func (p *PromptPane) processContextPickerUpdates(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + var cmds []tea.Cmd + + if p.isFocused && p.viewMode == util.ContextPickerMode { + if p.contextPicker.SelectedPath != "" { + // Create a context chip from the selected path + selectedPath := p.contextPicker.SelectedPath + selectedPath = filepath.Clean(selectedPath) + selectedPath = strings.ReplaceAll(selectedPath, `\ `, " ") + + // Get file info + info, err := os.Stat(selectedPath) + if err == nil { + chip := util.FileContextChip{ + Path: selectedPath, + Name: filepath.Base(selectedPath), + IsFolder: info.IsDir(), + Size: info.Size(), + Type: "file", + } + + if info.IsDir() { + chip.Type = "folder" + // Count files in folder (will be updated when reading) + chip.FileCount = 0 + } + + p.contextChips = append(p.contextChips, chip) + } + + cmds = append(cmds, util.SendViewModeChangedMsg(p.contextPicker.PrevView)) + p.contextPicker.SelectedPath = "" + } else { + p.contextPicker, cmd = p.contextPicker.Update(msg) + cmds = append(cmds, cmd) + } + } + + if !p.isFocused && p.viewMode == util.ContextPickerMode { + cmds = append(cmds, util.SendViewModeChangedMsg(util.NormalMode)) + } + + return tea.Batch(cmds...) +} + func (p *PromptPane) processTextInputUpdates(msg tea.Msg) tea.Cmd { var cmd tea.Cmd var cmds []tea.Cmd @@ -437,9 +492,20 @@ func (p *PromptPane) processTextInputUpdates(msg tea.Msg) tea.Cmd { switch p.viewMode { case util.FilePickerMode: break + case util.ContextPickerMode: + break case util.TextEditMode: 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] == ' ' { + return util.SendViewModeChangedMsg(util.ContextPickerMode) + } + } + // 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 { @@ -484,6 +550,8 @@ func (p *PromptPane) handleWindowSizeMsg(msg tea.WindowSizeMsg) tea.Cmd { case util.FilePickerMode: p.filePicker.SetSize(w, h) + case util.ContextPickerMode: + p.contextPicker.SetSize(w, h) case util.TextEditMode: p.textEditor.SetHeight(h) p.textEditor.SetWidth(w) @@ -513,6 +581,9 @@ func (p *PromptPane) handleViewModeChange(msg util.ViewModeChanged) tea.Cmd { case util.FilePickerMode: cmd = p.openFilePicker(prevMode, currentInput) + case util.ContextPickerMode: + cmd = p.openContextPicker(prevMode, currentInput) + default: cmd = p.openInputField(prevMode, currentInput) } @@ -566,6 +637,21 @@ func (p *PromptPane) openFilePicker(previousViewMode util.ViewMode, currentInput return p.filePicker.Init() } +func (p *PromptPane) openContextPicker(previousViewMode util.ViewMode, currentInput string) tea.Cmd { + w, h := util.CalcPromptPaneSize(p.terminalWidth, p.terminalHeight, p.viewMode) + // Get config values for context picker + cfg, ok := config.FromContext(p.mainCtx) + showIcons := true + maxDepth := 2 + if ok { + showIcons = cfg.ShowContextIcons + maxDepth = cfg.ContextMaxDepth + } + p.contextPicker = components.NewContextPicker(previousViewMode, currentInput, p.colors, showIcons, maxDepth) + p.contextPicker.SetSize(w, h) + return nil +} + func (p *PromptPane) openTextEditor(content string, op util.Operation, isFocused bool) tea.Cmd { p.operation = op @@ -668,6 +754,10 @@ func (p PromptPane) AllowFocusChange(isMouseEvent bool) bool { return false } + if p.isFocused && p.viewMode == util.ContextPickerMode { + return false + } + return true } @@ -683,22 +773,31 @@ func (p PromptPane) View() string { switch p.viewMode { case util.FilePickerMode: content = p.filePicker.View() + case util.ContextPickerMode: + content = p.contextPicker.View() case util.TextEditMode: content = p.textEditor.View() default: 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 { diff --git a/util/file-reader.go b/util/file-reader.go new file mode 100644 index 0000000..4a08aec --- /dev/null +++ b/util/file-reader.go @@ -0,0 +1,260 @@ +package util + +import ( + "bytes" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + "unicode/utf8" +) + +// ReadFileContent reads the content of a file and returns it as a string +// Returns an error if the file is binary or cannot be read +func ReadFileContent(path string) (string, error) { + 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 +} + +// IsTextFile checks if a file is a text file by reading a small portion +// and checking if it's valid UTF-8 +func IsTextFile(path string) bool { + file, err := os.Open(path) + if err != nil { + return false + } + defer file.Close() + + // Read first 512 bytes to check + buf := make([]byte, 512) + n, err := file.Read(buf) + if err != nil && err.Error() != "EOF" { + return false + } + + // Check if the content is valid UTF-8 + return utf8.Valid(buf[:n]) +} + +// 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() +} + +// DetectEncoding attempts to detect if a file is text or binary +// Returns true if the file appears to be text +func DetectEncoding(path string) bool { + file, err := os.Open(path) + if err != nil { + return false + } + defer file.Close() + + // Read first 8192 bytes to check + buf := make([]byte, 8192) + n, err := file.Read(buf) + if err != nil && err.Error() != "EOF" { + return false + } + + // Check for null bytes (common in binary files) + if bytes.Contains(buf[:n], []byte{0}) { + return false + } + + // Check if the content is valid UTF-8 + return utf8.Valid(buf[:n]) +} + +// CountFilesInFolder counts the number of non-media files in a folder +func CountFilesInFolder(path string, maxDepth int) (int, error) { + count := 0 + + 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 + if d.IsDir() { + return nil + } + + // Skip media files + ext := strings.ToLower(filepath.Ext(filePath)) + if slices.Contains(MediaExtensions, ext) { + return nil + } + + count++ + return nil + }) + + if err != nil { + return 0, fmt.Errorf("failed to count files in folder: %w", err) + } + + return count, nil +} diff --git a/util/shared-events.go b/util/shared-events.go index dd75bc5..4a7aecb 100644 --- a/util/shared-events.go +++ b/util/shared-events.go @@ -46,6 +46,7 @@ const ( TextEditMode NormalMode FilePickerMode + ContextPickerMode ) type Operation int @@ -121,8 +122,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 +136,16 @@ 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 AsyncDependencyReady struct { Dependency AsyncDependency } diff --git a/util/shared-types.go b/util/shared-types.go index a35a7fc..19495d9 100644 --- a/util/shared-types.go +++ b/util/shared-types.go @@ -112,3 +112,38 @@ 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 + Name string + IsFolder bool + Size int64 + FileCount int // Number of files if folder + Type string // "file" or "folder" +} + +// FileContext represents file metadata for context import +type FileContext struct { + Path string + Name string + IsFolder bool + Size int64 +} + +// 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 +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", ".txt", +} diff --git a/views/main-view.go b/views/main-view.go index 943cd08..c99009b 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" @@ -356,11 +357,21 @@ func (m MainView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + // Process context chips + contextContent := "" + if len(msg.ContextChips) > 0 { + util.Slog.Debug("processing context chips", "count", len(msg.ContextChips)) + contextContent = m.processContextChips(msg.ContextChips) + if contextContent != "" { + contextContent = "\n\n" + contextContent + } + } + m.sessionOrchestrator.ArrayOfMessages = append( m.sessionOrchestrator.ArrayOfMessages, util.LocalStoreMessage{ Role: "user", - Content: msg.Prompt, + Content: msg.Prompt + contextContent, Attachments: loadedAttachments, }) m.viewMode = util.NormalMode @@ -600,6 +611,41 @@ func mapAttachmentType(attachmentType string) string { return "" } +func (m MainView) processContextChips(chips []util.FileContextChip) string { + var contextContent strings.Builder + maxDepth := m.config.ContextMaxDepth + + for _, chip := range chips { + if chip.IsFolder { + // Read folder contents + util.Slog.Debug("reading folder context", "path", chip.Path) + contents, filePaths, err := util.ReadFolderContents(chip.Path, maxDepth) + if err != nil { + util.Slog.Error("failed to read folder", "path", chip.Path, "error", err.Error()) + continue + } + + // Format folder contents + formatted := util.FormatFolderContents(contents, filePaths) + contextContent.WriteString(formatted) + } else { + // Read single file + util.Slog.Debug("reading file context", "path", chip.Path) + content, err := util.ReadFileContent(chip.Path) + if err != nil { + util.Slog.Error("failed to read file", "path", chip.Path, "error", err.Error()) + continue + } + + // Format file content + formatted := util.FormatFileContent(chip.Path, content) + contextContent.WriteString(formatted) + } + } + + return contextContent.String() +} + // TODO: use event to lock/unlock allowFocusChange flag? func (m MainView) isFocusChangeAllowed(isMouseEvent bool) bool { if !m.promptPane.AllowFocusChange(isMouseEvent) || From 45d43cb9dc9fdaff4771fa6fa40df6ea7ead260a Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Tue, 10 Feb 2026 10:01:23 +0700 Subject: [PATCH 02/25] fix: make context files discovery recursive and fix phantom panes --- components/context-picker.go | 12 ++++++++++++ config/config-handler.go | 5 +++++ views/main-view.go | 36 +++++++++++++++++++++++++----------- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/components/context-picker.go b/components/context-picker.go index 5f04f24..a166854 100644 --- a/components/context-picker.go +++ b/components/context-picker.go @@ -175,7 +175,10 @@ func NewContextPicker(prevView util.ViewMode, prevInput string, colors util.Sche func (l *ContextPicker) scanDirectory(dir string, currentDepth, maxDepth int) []list.Item { var items []list.Item + util.Slog.Debug("scanDirectory called", "dir", dir, "currentDepth", currentDepth, "maxDepth", maxDepth) + if currentDepth > maxDepth { + util.Slog.Debug("scanDirectory: maxDepth reached, returning", "dir", dir, "currentDepth", currentDepth) return items } @@ -185,6 +188,8 @@ func (l *ContextPicker) scanDirectory(dir string, currentDepth, maxDepth int) [] return items } + util.Slog.Debug("scanDirectory: reading entries", "dir", dir, "entryCount", len(entries)) + for _, entry := range entries { name := entry.Name() @@ -201,6 +206,7 @@ func (l *ContextPicker) scanDirectory(dir string, currentDepth, maxDepth int) [] } if entry.IsDir() { + util.Slog.Debug("scanDirectory: adding folder", "path", fullPath, "name", name) items = append(items, ContextPickerItem{ Path: fullPath, Name: name, @@ -208,13 +214,18 @@ func (l *ContextPicker) scanDirectory(dir string, currentDepth, maxDepth int) [] Size: 0, Icon: "📁", }) + // Recursively scan subdirectories to add files from them + subItems := l.scanDirectory(fullPath, currentDepth+1, maxDepth) + items = append(items, subItems...) } else { // Check if it's a media file ext := strings.ToLower(filepath.Ext(name)) if slices.Contains(util.MediaExtensions, ext) { + util.Slog.Debug("scanDirectory: skipping media file", "path", fullPath, "ext", ext) continue } + util.Slog.Debug("scanDirectory: adding file", "path", fullPath, "name", name) items = append(items, ContextPickerItem{ Path: fullPath, Name: name, @@ -225,6 +236,7 @@ func (l *ContextPicker) scanDirectory(dir string, currentDepth, maxDepth int) [] } } + util.Slog.Debug("scanDirectory: returning items", "dir", dir, "itemCount", len(items)) return items } diff --git a/config/config-handler.go b/config/config-handler.go index 1112b12..ab14c38 100644 --- a/config/config-handler.go +++ b/config/config-handler.go @@ -231,6 +231,11 @@ 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 == 0 { + c.ContextMaxDepth = 2 + } } func (c *Config) applyFlags(flags StartupFlags) { diff --git a/views/main-view.go b/views/main-view.go index c99009b..365fcff 100644 --- a/views/main-view.go +++ b/views/main-view.go @@ -391,8 +391,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 @@ -403,6 +405,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 } @@ -541,20 +544,31 @@ func (m MainView) View() string { mainView = m.chatPane.DisplayError(m.error.Message) } - secondaryScreen := "" + // Conditionally render windowViews based on view mode + // In ContextPickerMode or FilePickerMode, don't render chat pane to avoid phantom pane 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.ContextPickerMode || m.viewMode == util.FilePickerMode { + // In ContextPickerMode or FilePickerMode, render empty windowViews + // This prevents the chat pane from being rendered and creating a clickable zone + windowViews = "" + } 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) promptView := m.promptPane.View() From f828162eb7f806bf463e5bd8b2589f53bdd05633 Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Wed, 11 Feb 2026 09:58:23 +0700 Subject: [PATCH 03/25] feat: added line-preview --- components/context-picker.go | 59 +++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/components/context-picker.go b/components/context-picker.go index a166854..59108bc 100644 --- a/components/context-picker.go +++ b/components/context-picker.go @@ -24,6 +24,7 @@ type ContextPicker struct { baseDir string showIcons bool maxDepth int + preview string // Preview of selected file (first 10 lines) } var contextPickerTips = "/ filter • enter select • esc cancel" @@ -83,9 +84,25 @@ func (l *ContextPicker) View() string { } else { l.list.SetShowStatusBar(true) } + + content := l.list.View() + + // Add preview section above the list if available + if l.preview != "" { + previewStyle := lipgloss.NewStyle(). + PaddingLeft(1). + MaxHeight(10) // Display 10 lines + + content = lipgloss.JoinVertical( + lipgloss.Left, + previewStyle.Render(l.preview), + content, + ) + } + return lipgloss.JoinVertical( lipgloss.Left, - l.list.View(), + content, util.HelpStyle.Render(contextPickerTips)) } @@ -132,9 +149,49 @@ func (l *ContextPicker) Update(msg tea.Msg) (ContextPicker, tea.Cmd) { } l.list, cmd = l.list.Update(msg) + + // Update preview when selection changes + if item, ok := l.GetSelectedItem(); ok && !item.IsFolder { + l.updatePreview(item.Path) + } else { + l.preview = "" + } + return *l, cmd } +func (l *ContextPicker) updatePreview(filePath string) { + // Get file info to check size + info, err := os.Stat(filePath) + if err != nil { + util.Slog.Error("failed to get file info for preview", "path", filePath, "error", err.Error()) + l.preview = "" + return + } + + // Don't preview files larger than 5MB to save performance + const maxPreviewSize = 5 * 1024 * 1024 // 5MB in bytes + if info.Size() > maxPreviewSize { + util.Slog.Debug("file too large for preview", "path", filePath, "size", info.Size()) + l.preview = "" + return + } + + content, err := os.ReadFile(filePath) + if err != nil { + util.Slog.Error("failed to read file for preview", "path", filePath, "error", err.Error()) + l.preview = "" + return + } + + lines := strings.Split(string(content), "\n") + if len(lines) > 10 { + lines = lines[:10] + } + + l.preview = strings.Join(lines, "\n") +} + func NewContextPicker(prevView util.ViewMode, prevInput string, colors util.SchemeColors, showIcons bool, maxDepth int) ContextPicker { baseDir, err := os.Getwd() if err != nil { From 769daf0518b7f51a8a755a12759f01089ca19ded Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Wed, 11 Feb 2026 14:52:49 +0700 Subject: [PATCH 04/25] chore: clean up coderabbit's autosuggestion --- components/context-picker.go | 94 +++++++++++++++++++++++++++++------- config/config-handler.go | 31 ++++++++++-- panes/prompt-pane.go | 8 +-- util/file-reader.go | 61 ++++++----------------- util/shared-events.go | 6 +++ util/shared-types.go | 8 --- views/main-view.go | 79 ++++++++++++++++++++++++++---- 7 files changed, 198 insertions(+), 89 deletions(-) diff --git a/components/context-picker.go b/components/context-picker.go index 59108bc..78ed368 100644 --- a/components/context-picker.go +++ b/components/context-picker.go @@ -1,6 +1,8 @@ package components import ( + "bufio" + "bytes" "fmt" "io" "os" @@ -24,7 +26,8 @@ type ContextPicker struct { baseDir string showIcons bool maxDepth int - preview string // Preview of selected file (first 10 lines) + preview string // Preview of selected file (first 10 lines) + previewPath string // Path of currently cached preview } var contextPickerTips = "/ filter • enter select • esc cancel" @@ -43,9 +46,11 @@ type ContextPickerItem struct { Icon string } -func (i ContextPickerItem) FilterValue() string { return zone.Mark(i.Path, i.Name) } +func (i ContextPickerItem) FilterValue() string { return i.Name } -type contextPickerItemDelegate struct{} +type contextPickerItemDelegate struct { + showIcons bool +} func (d contextPickerItemDelegate) Height() int { return 1 } func (d contextPickerItemDelegate) Spacing() int { return 0 } @@ -57,10 +62,12 @@ func (d contextPickerItemDelegate) Render(w io.Writer, m list.Model, index int, } icon := "" - if i.IsFolder { - icon = "📁 " - } else { - icon = "📄 " + if d.showIcons { + if i.IsFolder { + icon = "📁 " + } else { + icon = "📄 " + } } str := fmt.Sprintf("%s%s", icon, i.Name) @@ -126,8 +133,11 @@ func (l *ContextPicker) Update(msg tea.Msg) (ContextPicker, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "esc", "q": - l.quitting = true - return *l, util.SendViewModeChangedMsg(l.PrevView) + // Only quit if not actively filtering + if l.list.FilterState() != list.Filtering { + l.quitting = true + return *l, util.SendViewModeChangedMsg(l.PrevView) + } case "enter": if item, ok := l.GetSelectedItem(); ok { l.SelectedPath = item.Path @@ -150,11 +160,16 @@ func (l *ContextPicker) Update(msg tea.Msg) (ContextPicker, tea.Cmd) { l.list, cmd = l.list.Update(msg) - // Update preview when selection changes + // Update preview when selection changes (with caching) if item, ok := l.GetSelectedItem(); ok && !item.IsFolder { - l.updatePreview(item.Path) + // Only update preview if the path has changed + if item.Path != l.previewPath { + l.updatePreview(item.Path) + } } else { + // Clear preview when selection is nil or a folder l.preview = "" + l.previewPath = "" } return *l, cmd @@ -166,6 +181,7 @@ func (l *ContextPicker) updatePreview(filePath string) { if err != nil { util.Slog.Error("failed to get file info for preview", "path", filePath, "error", err.Error()) l.preview = "" + l.previewPath = "" return } @@ -174,22 +190,64 @@ func (l *ContextPicker) updatePreview(filePath string) { if info.Size() > maxPreviewSize { util.Slog.Debug("file too large for preview", "path", filePath, "size", info.Size()) l.preview = "" + l.previewPath = "" return } - content, err := os.ReadFile(filePath) + // Open file for reading + file, err := os.Open(filePath) if err != nil { - util.Slog.Error("failed to read file for preview", "path", filePath, "error", err.Error()) + util.Slog.Error("failed to open file for preview", "path", filePath, "error", err.Error()) l.preview = "" + l.previewPath = "" return } + defer file.Close() - lines := strings.Split(string(content), "\n") - if len(lines) > 10 { - lines = lines[:10] + // Peek at first 512 bytes to check for binary content + peekBuf := make([]byte, 512) + n, err := file.Read(peekBuf) + if err != nil && err != io.EOF { + util.Slog.Error("failed to peek at file for preview", "path", filePath, "error", err.Error()) + l.preview = "" + l.previewPath = "" + return } - + + // Check for null byte (binary file indicator) + if bytes.Contains(peekBuf[:n], []byte{0}) { + util.Slog.Debug("file appears to be binary, skipping preview", "path", filePath) + l.preview = "" + l.previewPath = "" + return + } + + // Reset file position to beginning + if _, err := file.Seek(0, 0); err != nil { + util.Slog.Error("failed to seek file for preview", "path", filePath, "error", err.Error()) + l.preview = "" + l.previewPath = "" + return + } + + // Read up to 10 lines using scanner + scanner := bufio.NewScanner(file) + var lines []string + lineCount := 0 + for scanner.Scan() && lineCount < 10 { + lines = append(lines, scanner.Text()) + lineCount++ + } + + if err := scanner.Err(); err != nil { + util.Slog.Error("failed to scan file for preview", "path", filePath, "error", err.Error()) + l.preview = "" + l.previewPath = "" + return + } + l.preview = strings.Join(lines, "\n") + l.previewPath = filePath } func NewContextPicker(prevView util.ViewMode, prevInput string, colors util.SchemeColors, showIcons bool, maxDepth int) ContextPicker { @@ -204,7 +262,7 @@ func NewContextPicker(prevView util.ViewMode, prevInput string, colors util.Sche items := tempPicker.scanDirectory(baseDir, 0, maxDepth) h := 20 // Default height - l := list.New(items, contextPickerItemDelegate{}, 80, h) + l := list.New(items, contextPickerItemDelegate{showIcons: showIcons}, 80, h) l.SetStatusBarItemName("item", "items") l.SetShowTitle(false) diff --git a/config/config-handler.go b/config/config-handler.go index ab14c38..3a226e0 100644 --- a/config/config-handler.go +++ b/config/config-handler.go @@ -53,8 +53,8 @@ type Config struct { MaxAttachmentSizeMb int `json:"maxAttachmentSizeMb"` IncludeReasoningTokensInContext *bool `json:"includeReasoningTokensInContext"` SessionExportDir string `json:"sessionExportDir"` - ContextMaxDepth int `json:"contextMaxDepth"` - ShowContextIcons bool `json:"showContextIcons"` + ContextMaxDepth *int `json:"contextMaxDepth"` + ShowContextIcons *bool `json:"showContextIcons"` } type StartupFlags struct { @@ -233,9 +233,32 @@ func (c *Config) setDefaults() { } // Set default context max depth to 2 for recursive file scanning - if c.ContextMaxDepth == 0 { - c.ContextMaxDepth = 2 + if c.ContextMaxDepth == nil { + defaultDepth := 2 + c.ContextMaxDepth = &defaultDepth } + + // Set default show context icons to true + if c.ShowContextIcons == nil { + defaultShowIcons := true + c.ShowContextIcons = &defaultShowIcons + } +} + +// 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 +} + +// GetShowContextIcons returns whether to show context icons, defaulting to true if nil +func (c *Config) GetShowContextIcons() bool { + if c.ShowContextIcons == nil { + return true + } + return *c.ShowContextIcons } func (c *Config) applyFlags(flags StartupFlags) { diff --git a/panes/prompt-pane.go b/panes/prompt-pane.go index 2530c75..cafab90 100644 --- a/panes/prompt-pane.go +++ b/panes/prompt-pane.go @@ -452,7 +452,9 @@ func (p *PromptPane) processContextPickerUpdates(msg tea.Msg) tea.Cmd { // Get file info info, err := os.Stat(selectedPath) - if err == nil { + if err != nil { + util.Slog.Error("os.Stat failed for context chip", "path", selectedPath, "error", err.Error()) + } else { chip := util.FileContextChip{ Path: selectedPath, Name: filepath.Base(selectedPath), @@ -644,8 +646,8 @@ func (p *PromptPane) openContextPicker(previousViewMode util.ViewMode, currentIn showIcons := true maxDepth := 2 if ok { - showIcons = cfg.ShowContextIcons - maxDepth = cfg.ContextMaxDepth + showIcons = cfg.GetShowContextIcons() + maxDepth = cfg.GetContextMaxDepth() } p.contextPicker = components.NewContextPicker(previousViewMode, currentInput, p.colors, showIcons, maxDepth) p.contextPicker.SetSize(w, h) diff --git a/util/file-reader.go b/util/file-reader.go index 4a08aec..e003605 100644 --- a/util/file-reader.go +++ b/util/file-reader.go @@ -1,7 +1,6 @@ package util import ( - "bytes" "fmt" "io/fs" "os" @@ -11,9 +10,22 @@ import ( "unicode/utf8" ) +const maxReadFileSize = 5 * 1024 * 1024 // 5MB + // ReadFileContent reads the content of a file and returns it as a string -// Returns an error if the file is binary or cannot be read +// 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) @@ -113,26 +125,6 @@ func GetFileSize(path string) (int64, error) { return info.Size(), nil } -// IsTextFile checks if a file is a text file by reading a small portion -// and checking if it's valid UTF-8 -func IsTextFile(path string) bool { - file, err := os.Open(path) - if err != nil { - return false - } - defer file.Close() - - // Read first 512 bytes to check - buf := make([]byte, 512) - n, err := file.Read(buf) - if err != nil && err.Error() != "EOF" { - return false - } - - // Check if the content is valid UTF-8 - return utf8.Valid(buf[:n]) -} - // 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 { @@ -175,31 +167,6 @@ func FormatFolderContents(contents map[string]string, filePaths []string) string return formatted.String() } -// DetectEncoding attempts to detect if a file is text or binary -// Returns true if the file appears to be text -func DetectEncoding(path string) bool { - file, err := os.Open(path) - if err != nil { - return false - } - defer file.Close() - - // Read first 8192 bytes to check - buf := make([]byte, 8192) - n, err := file.Read(buf) - if err != nil && err.Error() != "EOF" { - return false - } - - // Check for null bytes (common in binary files) - if bytes.Contains(buf[:n], []byte{0}) { - return false - } - - // Check if the content is valid UTF-8 - return utf8.Valid(buf[:n]) -} - // CountFilesInFolder counts the number of non-media files in a folder func CountFilesInFolder(path string, maxDepth int) (int, error) { count := 0 diff --git a/util/shared-events.go b/util/shared-events.go index 4a7aecb..f0d523d 100644 --- a/util/shared-events.go +++ b/util/shared-events.go @@ -146,6 +146,12 @@ func SendPromptReadyWithContextMsg(prompt string, attachments []Attachment, cont } } +type ContextChipsProcessed struct { + Prompt string + Attachments []Attachment + ContextContent string +} + type AsyncDependencyReady struct { Dependency AsyncDependency } diff --git a/util/shared-types.go b/util/shared-types.go index 19495d9..84bf215 100644 --- a/util/shared-types.go +++ b/util/shared-types.go @@ -123,14 +123,6 @@ type FileContextChip struct { Type string // "file" or "folder" } -// FileContext represents file metadata for context import -type FileContext struct { - Path string - Name string - IsFolder bool - Size int64 -} - // MediaExtensions contains file extensions to exclude from context import var MediaExtensions = []string{ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", diff --git a/views/main-view.go b/views/main-view.go index 365fcff..106d79a 100644 --- a/views/main-view.go +++ b/views/main-view.go @@ -357,21 +357,18 @@ func (m MainView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // Process context chips - contextContent := "" + // Process context chips asynchronously if len(msg.ContextChips) > 0 { - util.Slog.Debug("processing context chips", "count", len(msg.ContextChips)) - contextContent = m.processContextChips(msg.ContextChips) - if contextContent != "" { - contextContent = "\n\n" + contextContent - } + util.Slog.Debug("dispatching async context chips processing", "count", len(msg.ContextChips)) + return m, m.makeProcessContextChipsCmd(msg.Prompt, loadedAttachments, msg.ContextChips) } + // No context chips, proceed directly m.sessionOrchestrator.ArrayOfMessages = append( m.sessionOrchestrator.ArrayOfMessages, util.LocalStoreMessage{ Role: "user", - Content: msg.Prompt + contextContent, + Content: msg.Prompt, Attachments: loadedAttachments, }) m.viewMode = util.NormalMode @@ -383,6 +380,29 @@ 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 + contextContent := msg.ContextContent + if contextContent != "" { + contextContent = "\n\n" + contextContent + } + + m.sessionOrchestrator.ArrayOfMessages = append( + m.sessionOrchestrator.ArrayOfMessages, + util.LocalStoreMessage{ + Role: "user", + Content: msg.Prompt + contextContent, + Attachments: msg.Attachments, + }) + m.viewMode = util.NormalMode + m.controlsLocked = true + + m.setProcessingContext() + return m, tea.Sequence( + util.SendProcessingStateChangedMsg(util.ProcessingChunks), + util.SendViewModeChangedMsg(m.viewMode), + m.chatPane.DisplayCompletion(m.processingCtx, &m.sessionOrchestrator)) + case tea.MouseMsg: targetPane := m.focused @@ -625,9 +645,50 @@ func mapAttachmentType(attachmentType string) string { return "" } +func (m MainView) makeProcessContextChipsCmd(prompt string, attachments []util.Attachment, chips []util.FileContextChip) tea.Cmd { + return func() tea.Msg { + var contextContent strings.Builder + maxDepth := m.config.GetContextMaxDepth() + + for _, chip := range chips { + if chip.IsFolder { + // Read folder contents + util.Slog.Debug("reading folder context", "path", chip.Path) + contents, filePaths, err := util.ReadFolderContents(chip.Path, maxDepth) + if err != nil { + util.Slog.Error("failed to read folder", "path", chip.Path, "error", err.Error()) + continue + } + + // Format folder contents + formatted := util.FormatFolderContents(contents, filePaths) + contextContent.WriteString(formatted) + } else { + // Read single file + util.Slog.Debug("reading file context", "path", chip.Path) + content, err := util.ReadFileContent(chip.Path) + if err != nil { + util.Slog.Error("failed to read file", "path", chip.Path, "error", err.Error()) + continue + } + + // Format file content + formatted := util.FormatFileContent(chip.Path, content) + contextContent.WriteString(formatted) + } + } + + return util.ContextChipsProcessed{ + Prompt: prompt, + Attachments: attachments, + ContextContent: contextContent.String(), + } + } +} + func (m MainView) processContextChips(chips []util.FileContextChip) string { var contextContent strings.Builder - maxDepth := m.config.ContextMaxDepth + maxDepth := m.config.GetContextMaxDepth() for _, chip := range chips { if chip.IsFolder { From c46c3d997b1d82bc79f7c90dee734d9103a5a463 Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Wed, 11 Feb 2026 15:09:03 +0700 Subject: [PATCH 05/25] chore: more fixes for maxDepth race condition, binary detection and filter edge cases --- components/context-picker.go | 49 +++++++++++++++++++++++++++--------- util/shared-events.go | 7 +++--- views/main-view.go | 19 +++++++++++--- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/components/context-picker.go b/components/context-picker.go index 78ed368..30e6cc4 100644 --- a/components/context-picker.go +++ b/components/context-picker.go @@ -2,13 +2,14 @@ package components import ( "bufio" - "bytes" "fmt" "io" "os" "path/filepath" "slices" "strings" + "time" + "unicode/utf8" "github.com/BalanceBalls/nekot/util" "github.com/charmbracelet/bubbles/list" @@ -26,8 +27,9 @@ type ContextPicker struct { baseDir string showIcons bool maxDepth int - preview string // Preview of selected file (first 10 lines) - previewPath string // Path of currently cached preview + preview string // Preview of selected file (first 10 lines) + previewPath string // Path of currently cached preview + previewModTime time.Time // Modification time of cached preview file } var contextPickerTips = "/ filter • enter select • esc cancel" @@ -133,8 +135,8 @@ func (l *ContextPicker) Update(msg tea.Msg) (ContextPicker, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "esc", "q": - // Only quit if not actively filtering - if l.list.FilterState() != list.Filtering { + // Only quit if not actively filtering (check both FilterState and filter input focus) + if l.list.FilterState() != list.Filtering && !l.list.FilterInput.Focused() { l.quitting = true return *l, util.SendViewModeChangedMsg(l.PrevView) } @@ -162,26 +164,29 @@ func (l *ContextPicker) Update(msg tea.Msg) (ContextPicker, tea.Cmd) { // Update preview when selection changes (with caching) if item, ok := l.GetSelectedItem(); ok && !item.IsFolder { - // Only update preview if the path has changed - if item.Path != l.previewPath { - l.updatePreview(item.Path) + // Only update preview if the path has changed or file was modified + info, err := os.Stat(item.Path) + if err == nil && (item.Path != l.previewPath || info.ModTime() != l.previewModTime) { + l.updatePreview(item.Path, info.ModTime()) } } else { // Clear preview when selection is nil or a folder l.preview = "" l.previewPath = "" + l.previewModTime = time.Time{} } return *l, cmd } -func (l *ContextPicker) updatePreview(filePath string) { +func (l *ContextPicker) updatePreview(filePath string, modTime time.Time) { // Get file info to check size info, err := os.Stat(filePath) if err != nil { util.Slog.Error("failed to get file info for preview", "path", filePath, "error", err.Error()) l.preview = "" l.previewPath = "" + l.previewModTime = time.Time{} return } @@ -191,6 +196,7 @@ func (l *ContextPicker) updatePreview(filePath string) { util.Slog.Debug("file too large for preview", "path", filePath, "size", info.Size()) l.preview = "" l.previewPath = "" + l.previewModTime = time.Time{} return } @@ -214,9 +220,27 @@ func (l *ContextPicker) updatePreview(filePath string) { return } - // Check for null byte (binary file indicator) - if bytes.Contains(peekBuf[:n], []byte{0}) { - util.Slog.Debug("file appears to be binary, skipping preview", "path", filePath) + // Check for binary content: + // 1. Multiple null bytes (more than 1 is strong indicator of binary) + // 2. Invalid UTF-8 sequences + nullByteCount := 0 + for i := 0; i < n; i++ { + if peekBuf[i] == 0 { + nullByteCount++ + } + } + + // More than 1 null byte in first 512 bytes is likely binary + if nullByteCount > 1 { + util.Slog.Debug("file appears to be binary (multiple null bytes), skipping preview", "path", filePath, "nullCount", nullByteCount) + l.preview = "" + l.previewPath = "" + return + } + + // Check if content is valid UTF-8 + if !utf8.Valid(peekBuf[:n]) { + util.Slog.Debug("file appears to be binary (invalid UTF-8), skipping preview", "path", filePath) l.preview = "" l.previewPath = "" return @@ -248,6 +272,7 @@ func (l *ContextPicker) updatePreview(filePath string) { l.preview = strings.Join(lines, "\n") l.previewPath = filePath + l.previewModTime = modTime } func NewContextPicker(prevView util.ViewMode, prevInput string, colors util.SchemeColors, showIcons bool, maxDepth int) ContextPicker { diff --git a/util/shared-events.go b/util/shared-events.go index f0d523d..95c56a2 100644 --- a/util/shared-events.go +++ b/util/shared-events.go @@ -147,9 +147,10 @@ func SendPromptReadyWithContextMsg(prompt string, attachments []Attachment, cont } type ContextChipsProcessed struct { - Prompt string - Attachments []Attachment - ContextContent string + Prompt string + Attachments []Attachment + ContextContent string + Errors []string // Errors that occurred during processing } type AsyncDependencyReady struct { diff --git a/views/main-view.go b/views/main-view.go index 106d79a..982c4e9 100644 --- a/views/main-view.go +++ b/views/main-view.go @@ -360,7 +360,9 @@ func (m MainView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Process context chips asynchronously if len(msg.ContextChips) > 0 { util.Slog.Debug("dispatching async context chips processing", "count", len(msg.ContextChips)) - return m, m.makeProcessContextChipsCmd(msg.Prompt, loadedAttachments, 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 @@ -387,6 +389,12 @@ func (m MainView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { contextContent = "\n\n" + contextContent } + // Show notification if there were errors during processing + if len(msg.Errors) > 0 { + errorMsg := fmt.Sprintf("Some context files failed to load:\n%s", strings.Join(msg.Errors, "\n")) + return m, util.MakeErrorMsg(errorMsg) + } + m.sessionOrchestrator.ArrayOfMessages = append( m.sessionOrchestrator.ArrayOfMessages, util.LocalStoreMessage{ @@ -645,10 +653,10 @@ func mapAttachmentType(attachmentType string) string { return "" } -func (m MainView) makeProcessContextChipsCmd(prompt string, attachments []util.Attachment, chips []util.FileContextChip) tea.Cmd { +func (m MainView) makeProcessContextChipsCmd(prompt string, attachments []util.Attachment, chips []util.FileContextChip, maxDepth int) tea.Cmd { return func() tea.Msg { var contextContent strings.Builder - maxDepth := m.config.GetContextMaxDepth() + var errors []string for _, chip := range chips { if chip.IsFolder { @@ -656,7 +664,9 @@ func (m MainView) makeProcessContextChipsCmd(prompt string, attachments []util.A util.Slog.Debug("reading folder context", "path", chip.Path) 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 } @@ -668,7 +678,9 @@ func (m MainView) makeProcessContextChipsCmd(prompt string, attachments []util.A 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 } @@ -682,6 +694,7 @@ func (m MainView) makeProcessContextChipsCmd(prompt string, attachments []util.A Prompt: prompt, Attachments: attachments, ContextContent: contextContent.String(), + Errors: errors, } } } From cd5dc7d0617bca2ee4e98ca3de4de0a1d12c4cc1 Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Wed, 11 Feb 2026 15:45:57 +0700 Subject: [PATCH 06/25] chore: more coderabbit fixes --- components/context-picker.go | 16 ++++++++++++++++ views/main-view.go | 21 +++++++++++++-------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/components/context-picker.go b/components/context-picker.go index 30e6cc4..88afe7c 100644 --- a/components/context-picker.go +++ b/components/context-picker.go @@ -94,8 +94,24 @@ func (l *ContextPicker) View() string { l.list.SetShowStatusBar(true) } + // Adjust list height when preview is shown to prevent overflow + previewHeight := 0 + if l.preview != "" { + previewHeight = 10 // MaxHeight(10) from previewStyle + currentHeight := l.list.Height() + if currentHeight > previewHeight { + l.list.SetHeight(currentHeight - previewHeight) + } + } + content := l.list.View() + // Restore original list height after rendering + if l.preview != "" { + currentHeight := l.list.Height() + l.list.SetHeight(currentHeight + previewHeight) + } + // Add preview section above the list if available if l.preview != "" { previewStyle := lipgloss.NewStyle(). diff --git a/views/main-view.go b/views/main-view.go index 982c4e9..df3c54f 100644 --- a/views/main-view.go +++ b/views/main-view.go @@ -389,12 +389,6 @@ func (m MainView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { contextContent = "\n\n" + contextContent } - // Show notification if there were errors during processing - if len(msg.Errors) > 0 { - errorMsg := fmt.Sprintf("Some context files failed to load:\n%s", strings.Join(msg.Errors, "\n")) - return m, util.MakeErrorMsg(errorMsg) - } - m.sessionOrchestrator.ArrayOfMessages = append( m.sessionOrchestrator.ArrayOfMessages, util.LocalStoreMessage{ @@ -406,10 +400,21 @@ func (m MainView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.controlsLocked = true m.setProcessingContext() - return m, tea.Sequence( + + // Build the sequence of commands + sequence := []tea.Cmd{ util.SendProcessingStateChangedMsg(util.ProcessingChunks), util.SendViewModeChangedMsg(m.viewMode), - m.chatPane.DisplayCompletion(m.processingCtx, &m.sessionOrchestrator)) + 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")) + sequence = append(sequence, util.MakeErrorMsg(errorMsg)) + } + + return m, tea.Sequence(sequence...) case tea.MouseMsg: targetPane := m.focused From 18c4ea09a96ba92184c62a3378d2b20a9ffc1541 Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Wed, 11 Feb 2026 19:21:09 +0700 Subject: [PATCH 07/25] chore: even more coderabbit fixes --- components/context-picker.go | 2 ++ views/main-view.go | 37 +----------------------------------- 2 files changed, 3 insertions(+), 36 deletions(-) diff --git a/components/context-picker.go b/components/context-picker.go index 88afe7c..141fd44 100644 --- a/components/context-picker.go +++ b/components/context-picker.go @@ -222,6 +222,7 @@ func (l *ContextPicker) updatePreview(filePath string, modTime time.Time) { util.Slog.Error("failed to open file for preview", "path", filePath, "error", err.Error()) l.preview = "" l.previewPath = "" + l.previewModTime = time.Time{} return } defer file.Close() @@ -233,6 +234,7 @@ func (l *ContextPicker) updatePreview(filePath string, modTime time.Time) { util.Slog.Error("failed to peek at file for preview", "path", filePath, "error", err.Error()) l.preview = "" l.previewPath = "" + l.previewModTime = time.Time{} return } diff --git a/views/main-view.go b/views/main-view.go index df3c54f..3c8eb99 100644 --- a/views/main-view.go +++ b/views/main-view.go @@ -411,7 +411,7 @@ func (m MainView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // 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")) - sequence = append(sequence, util.MakeErrorMsg(errorMsg)) + util.Slog.Warn("some context files failed to load", "errors", errorMsg) } return m, tea.Sequence(sequence...) @@ -704,41 +704,6 @@ func (m MainView) makeProcessContextChipsCmd(prompt string, attachments []util.A } } -func (m MainView) processContextChips(chips []util.FileContextChip) string { - var contextContent strings.Builder - maxDepth := m.config.GetContextMaxDepth() - - for _, chip := range chips { - if chip.IsFolder { - // Read folder contents - util.Slog.Debug("reading folder context", "path", chip.Path) - contents, filePaths, err := util.ReadFolderContents(chip.Path, maxDepth) - if err != nil { - util.Slog.Error("failed to read folder", "path", chip.Path, "error", err.Error()) - continue - } - - // Format folder contents - formatted := util.FormatFolderContents(contents, filePaths) - contextContent.WriteString(formatted) - } else { - // Read single file - util.Slog.Debug("reading file context", "path", chip.Path) - content, err := util.ReadFileContent(chip.Path) - if err != nil { - util.Slog.Error("failed to read file", "path", chip.Path, "error", err.Error()) - continue - } - - // Format file content - formatted := util.FormatFileContent(chip.Path, content) - contextContent.WriteString(formatted) - } - } - - return contextContent.String() -} - // TODO: use event to lock/unlock allowFocusChange flag? func (m MainView) isFocusChangeAllowed(isMouseEvent bool) bool { if !m.promptPane.AllowFocusChange(isMouseEvent) || From da6c791385d475b82e50f7e016de9d86ce6e8fe7 Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Sat, 14 Feb 2026 16:01:12 +0700 Subject: [PATCH 08/25] fix: reuse file picker components, lots of bug fixes --- clients/gemini.go | 9 + clients/openai.go | 9 + clients/openrouter.go | 9 + components/context-picker.go | 406 ----------------------------------- components/file-picker.go | 278 +++++++++++++++++++++++- panes/chat-pane.go | 45 +++- panes/prompt-pane.go | 184 ++++++++-------- util/formatter.go | 29 ++- util/shared-events.go | 21 +- util/shared-types.go | 26 +-- views/main-view.go | 110 ++++++++-- 11 files changed, 575 insertions(+), 551 deletions(-) delete mode 100644 components/context-picker.go diff --git a/clients/gemini.go b/clients/gemini.go index ac9c25c..be9a282 100644 --- a/clients/gemini.go +++ b/clients/gemini.go @@ -442,6 +442,15 @@ func buildChatHistory(msgs []util.LocalStoreMessage, includeReasoning bool) ([]* messageContent += singleMessage.Content } + // FIX: Include ContextContent in the message sent to LLM + if singleMessage.ContextContent != "" { + util.Slog.Debug("Gemini: Including ContextContent in message (FIXED)", + "contextContentLength", len(singleMessage.ContextContent)) + messageContent += singleMessage.ContextContent + } else { + util.Slog.Debug("Gemini: No ContextContent in message") + } + message := genai.Content{ Parts: []genai.Part{}, Role: role, diff --git a/clients/openai.go b/clients/openai.go index 8998b14..fa62b46 100644 --- a/clients/openai.go +++ b/clients/openai.go @@ -270,6 +270,15 @@ func (c OpenAiClient) constructCompletionRequestPayload( messageContent += singleMessage.Content } + // FIX: Include ContextContent in the message sent to LLM + if singleMessage.ContextContent != "" { + util.Slog.Debug("OpenAI: Including ContextContent in message (FIXED)", + "contextContentLength", len(singleMessage.ContextContent)) + messageContent += singleMessage.ContextContent + } else { + util.Slog.Debug("OpenAI: No ContextContent in message") + } + if messageContent != "" { singleMessage.Content = messageContent } diff --git a/clients/openrouter.go b/clients/openrouter.go index 7f138cf..1f6ed86 100644 --- a/clients/openrouter.go +++ b/clients/openrouter.go @@ -267,6 +267,15 @@ func setRequestContext( messageContent += singleMessage.Content } + // FIX: Include ContextContent in the message sent to LLM + if singleMessage.ContextContent != "" { + util.Slog.Debug("OpenRouter: Including ContextContent in message (FIXED)", + "contextContentLength", len(singleMessage.ContextContent)) + messageContent += singleMessage.ContextContent + } else { + util.Slog.Debug("OpenRouter: No ContextContent in message") + } + if len(singleMessage.ToolCalls) > 0 { toolCalls := constructOpenrouterToolCalls(singleMessage) chat = append(chat, toolCalls...) diff --git a/components/context-picker.go b/components/context-picker.go deleted file mode 100644 index 141fd44..0000000 --- a/components/context-picker.go +++ /dev/null @@ -1,406 +0,0 @@ -package components - -import ( - "bufio" - "fmt" - "io" - "os" - "path/filepath" - "slices" - "strings" - "time" - "unicode/utf8" - - "github.com/BalanceBalls/nekot/util" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - zone "github.com/lrstanley/bubblezone" -) - -type ContextPicker struct { - list list.Model - SelectedPath string - PrevView util.ViewMode - PrevInput string - quitting bool - baseDir string - showIcons bool - maxDepth int - preview string // Preview of selected file (first 10 lines) - previewPath string // Path of currently cached preview - previewModTime time.Time // Modification time of cached preview file -} - -var contextPickerTips = "/ filter • enter select • esc cancel" - -var contextPickerListItemSpan = lipgloss.NewStyle(). - PaddingLeft(util.ListItemPaddingLeft) - -var contextPickerListItemSpanSelected = lipgloss.NewStyle(). - PaddingLeft(util.ListItemPaddingLeft) - -type ContextPickerItem struct { - Path string - Name string - IsFolder bool - Size int64 - Icon string -} - -func (i ContextPickerItem) FilterValue() string { return i.Name } - -type contextPickerItemDelegate struct { - showIcons bool -} - -func (d contextPickerItemDelegate) Height() int { return 1 } -func (d contextPickerItemDelegate) Spacing() int { return 0 } -func (d contextPickerItemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } -func (d contextPickerItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - i, ok := listItem.(ContextPickerItem) - if !ok { - return - } - - icon := "" - if d.showIcons { - if i.IsFolder { - icon = "📁 " - } else { - icon = "📄 " - } - } - - str := fmt.Sprintf("%s%s", icon, i.Name) - str = util.TrimListItem(str, m.Width()) - str = zone.Mark(i.Path, str) - - fn := contextPickerListItemSpan.Render - if index == m.Index() { - fn = func(s ...string) string { - row := "> " + strings.Join(s, " ") - return contextPickerListItemSpanSelected.Render(row) - } - } - - fmt.Fprint(w, fn(str)) -} - -func (l *ContextPicker) View() string { - if l.list.FilterState() == list.Filtering { - l.list.SetShowStatusBar(false) - } else { - l.list.SetShowStatusBar(true) - } - - // Adjust list height when preview is shown to prevent overflow - previewHeight := 0 - if l.preview != "" { - previewHeight = 10 // MaxHeight(10) from previewStyle - currentHeight := l.list.Height() - if currentHeight > previewHeight { - l.list.SetHeight(currentHeight - previewHeight) - } - } - - content := l.list.View() - - // Restore original list height after rendering - if l.preview != "" { - currentHeight := l.list.Height() - l.list.SetHeight(currentHeight + previewHeight) - } - - // Add preview section above the list if available - if l.preview != "" { - previewStyle := lipgloss.NewStyle(). - PaddingLeft(1). - MaxHeight(10) // Display 10 lines - - content = lipgloss.JoinVertical( - lipgloss.Left, - previewStyle.Render(l.preview), - content, - ) - } - - return lipgloss.JoinVertical( - lipgloss.Left, - content, - util.HelpStyle.Render(contextPickerTips)) -} - -func (l *ContextPicker) GetSelectedItem() (ContextPickerItem, bool) { - item, ok := l.list.SelectedItem().(ContextPickerItem) - return item, ok -} - -func (l ContextPicker) VisibleItems() []list.Item { - return l.list.VisibleItems() -} - -func (l ContextPicker) IsFiltering() bool { - return l.list.SettingFilter() -} - -func (l *ContextPicker) Update(msg tea.Msg) (ContextPicker, tea.Cmd) { - var cmd tea.Cmd - switch msg := msg.(type) { - - case tea.KeyMsg: - switch msg.String() { - case "esc", "q": - // Only quit if not actively filtering (check both FilterState and filter input focus) - if l.list.FilterState() != list.Filtering && !l.list.FilterInput.Focused() { - l.quitting = true - return *l, util.SendViewModeChangedMsg(l.PrevView) - } - case "enter": - if item, ok := l.GetSelectedItem(); ok { - l.SelectedPath = item.Path - l.quitting = true - return *l, util.SendViewModeChangedMsg(l.PrevView) - } - } - - case tea.MouseMsg: - if msg.Button == tea.MouseButtonWheelUp { - l.list.CursorUp() - return *l, nil - } - - if msg.Button == tea.MouseButtonWheelDown { - l.list.CursorDown() - return *l, nil - } - } - - l.list, cmd = l.list.Update(msg) - - // Update preview when selection changes (with caching) - if item, ok := l.GetSelectedItem(); ok && !item.IsFolder { - // Only update preview if the path has changed or file was modified - info, err := os.Stat(item.Path) - if err == nil && (item.Path != l.previewPath || info.ModTime() != l.previewModTime) { - l.updatePreview(item.Path, info.ModTime()) - } - } else { - // Clear preview when selection is nil or a folder - l.preview = "" - l.previewPath = "" - l.previewModTime = time.Time{} - } - - return *l, cmd -} - -func (l *ContextPicker) updatePreview(filePath string, modTime time.Time) { - // Get file info to check size - info, err := os.Stat(filePath) - if err != nil { - util.Slog.Error("failed to get file info for preview", "path", filePath, "error", err.Error()) - l.preview = "" - l.previewPath = "" - l.previewModTime = time.Time{} - return - } - - // Don't preview files larger than 5MB to save performance - const maxPreviewSize = 5 * 1024 * 1024 // 5MB in bytes - if info.Size() > maxPreviewSize { - util.Slog.Debug("file too large for preview", "path", filePath, "size", info.Size()) - l.preview = "" - l.previewPath = "" - l.previewModTime = time.Time{} - return - } - - // Open file for reading - file, err := os.Open(filePath) - if err != nil { - util.Slog.Error("failed to open file for preview", "path", filePath, "error", err.Error()) - l.preview = "" - l.previewPath = "" - l.previewModTime = time.Time{} - return - } - defer file.Close() - - // Peek at first 512 bytes to check for binary content - peekBuf := make([]byte, 512) - n, err := file.Read(peekBuf) - if err != nil && err != io.EOF { - util.Slog.Error("failed to peek at file for preview", "path", filePath, "error", err.Error()) - l.preview = "" - l.previewPath = "" - l.previewModTime = time.Time{} - return - } - - // Check for binary content: - // 1. Multiple null bytes (more than 1 is strong indicator of binary) - // 2. Invalid UTF-8 sequences - nullByteCount := 0 - for i := 0; i < n; i++ { - if peekBuf[i] == 0 { - nullByteCount++ - } - } - - // More than 1 null byte in first 512 bytes is likely binary - if nullByteCount > 1 { - util.Slog.Debug("file appears to be binary (multiple null bytes), skipping preview", "path", filePath, "nullCount", nullByteCount) - l.preview = "" - l.previewPath = "" - return - } - - // Check if content is valid UTF-8 - if !utf8.Valid(peekBuf[:n]) { - util.Slog.Debug("file appears to be binary (invalid UTF-8), skipping preview", "path", filePath) - l.preview = "" - l.previewPath = "" - return - } - - // Reset file position to beginning - if _, err := file.Seek(0, 0); err != nil { - util.Slog.Error("failed to seek file for preview", "path", filePath, "error", err.Error()) - l.preview = "" - l.previewPath = "" - return - } - - // Read up to 10 lines using scanner - scanner := bufio.NewScanner(file) - var lines []string - lineCount := 0 - for scanner.Scan() && lineCount < 10 { - lines = append(lines, scanner.Text()) - lineCount++ - } - - if err := scanner.Err(); err != nil { - util.Slog.Error("failed to scan file for preview", "path", filePath, "error", err.Error()) - l.preview = "" - l.previewPath = "" - return - } - - l.preview = strings.Join(lines, "\n") - l.previewPath = filePath - l.previewModTime = modTime -} - -func NewContextPicker(prevView util.ViewMode, prevInput string, colors util.SchemeColors, showIcons bool, maxDepth int) ContextPicker { - baseDir, err := os.Getwd() - if err != nil { - util.Slog.Error("failed to get current directory", "error", err.Error()) - baseDir, _ = os.UserHomeDir() - } - - // Create a temporary ContextPicker to call scanDirectory - tempPicker := ContextPicker{} - items := tempPicker.scanDirectory(baseDir, 0, maxDepth) - - h := 20 // Default height - l := list.New(items, contextPickerItemDelegate{showIcons: showIcons}, 80, h) - - l.SetStatusBarItemName("item", "items") - l.SetShowTitle(false) - l.SetShowHelp(false) - l.SetFilteringEnabled(true) - l.DisableQuitKeybindings() - - l.Paginator.ActiveDot = lipgloss.NewStyle().Foreground(colors.HighlightColor).Render(util.ActiveDot) - l.Paginator.InactiveDot = lipgloss.NewStyle().Foreground(colors.DefaultTextColor).Render(util.InactiveDot) - contextPickerListItemSpan = contextPickerListItemSpan.Foreground(colors.DefaultTextColor) - contextPickerListItemSpanSelected = contextPickerListItemSpanSelected.Foreground(colors.AccentColor) - l.FilterInput.PromptStyle = l.FilterInput.PromptStyle.Foreground(colors.ActiveTabBorderColor).PaddingBottom(0).Margin(0) - l.FilterInput.Cursor.Style = l.FilterInput.Cursor.Style.Foreground(colors.NormalTabBorderColor) - - return ContextPicker{ - list: l, - PrevView: prevView, - PrevInput: prevInput, - baseDir: baseDir, - showIcons: showIcons, - maxDepth: maxDepth, - } -} - -func (l *ContextPicker) scanDirectory(dir string, currentDepth, maxDepth int) []list.Item { - var items []list.Item - - util.Slog.Debug("scanDirectory called", "dir", dir, "currentDepth", currentDepth, "maxDepth", maxDepth) - - if currentDepth > maxDepth { - util.Slog.Debug("scanDirectory: maxDepth reached, returning", "dir", dir, "currentDepth", currentDepth) - return items - } - - entries, err := os.ReadDir(dir) - if err != nil { - util.Slog.Error("failed to read directory", "path", dir, "error", err.Error()) - return items - } - - util.Slog.Debug("scanDirectory: reading entries", "dir", dir, "entryCount", len(entries)) - - for _, entry := range entries { - name := entry.Name() - - // Skip hidden files - if strings.HasPrefix(name, ".") { - continue - } - - fullPath := filepath.Join(dir, name) - - info, err := entry.Info() - if err != nil { - continue - } - - if entry.IsDir() { - util.Slog.Debug("scanDirectory: adding folder", "path", fullPath, "name", name) - items = append(items, ContextPickerItem{ - Path: fullPath, - Name: name, - IsFolder: true, - Size: 0, - Icon: "📁", - }) - // Recursively scan subdirectories to add files from them - subItems := l.scanDirectory(fullPath, currentDepth+1, maxDepth) - items = append(items, subItems...) - } else { - // Check if it's a media file - ext := strings.ToLower(filepath.Ext(name)) - if slices.Contains(util.MediaExtensions, ext) { - util.Slog.Debug("scanDirectory: skipping media file", "path", fullPath, "ext", ext) - continue - } - - util.Slog.Debug("scanDirectory: adding file", "path", fullPath, "name", name) - items = append(items, ContextPickerItem{ - Path: fullPath, - Name: name, - IsFolder: false, - Size: info.Size(), - Icon: "📄", - }) - } - } - - util.Slog.Debug("scanDirectory: returning items", "dir", dir, "itemCount", len(items)) - return items -} - -func (l *ContextPicker) SetSize(w, h int) { - if w > 2 && h > 2 { - l.list.SetWidth(w) - l.list.SetHeight(h - 1) // Account for tips row - } -} diff --git a/components/file-picker.go b/components/file-picker.go index 4d28986..2cd2b5a 100644 --- a/components/file-picker.go +++ b/components/file-picker.go @@ -2,12 +2,16 @@ package components import ( "errors" + "fmt" "os" + "strings" "time" "github.com/BalanceBalls/nekot/util" "github.com/charmbracelet/bubbles/filepicker" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) type FilePicker struct { @@ -17,12 +21,25 @@ 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 + // Currently selected file for preview (for context mode) + previewFile string + // Preview content + previewContent string } func NewFilePicker( prevView util.ViewMode, prevInput string, colors util.SchemeColors, + isContextMode bool, ) FilePicker { fp := filepicker.New() @@ -38,15 +55,37 @@ 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 = "/" + 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{}, } return filePicker } @@ -66,8 +105,48 @@ 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": + keyStr := msg.String() + + // Debug: Log all key events when in FilePickerMode + util.Slog.Debug("FilePicker: Key event received", "keyStr", keyStr, "isContextMode", m.IsContextMode) + + // Handle Ctrl+/ (or Ctrl+_ on some keyboards) to focus filter input + if keyStr == "ctrl+/" || keyStr == "ctrl+_" { + util.Slog.Debug("FilePicker: Ctrl+/ detected, focusing filter input") + m.filterInputFocused = true + m.filterInput.Focus() + return m, nil + } + + // Track filter input focus state by monitoring key events + // If user types a printable character, they're likely in filter input + if len(keyStr) == 1 && keyStr >= " " && keyStr <= "~" { + // Printable character typed, user is in filter input + m.filterInputFocused = true + } else if keyStr == "enter" || keyStr == "tab" { + // Enter or tab pressed, blur filter input + m.filterInputFocused = false + m.filterInput.Blur() + } + + 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 "q": + // 'q' always closes the picker immediately m.quitting = true return m, util.SendViewModeChangedMsg(m.PrevView) } @@ -77,26 +156,135 @@ 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 { + m.filterInput, filterCmd = m.filterInput.Update(msg) + // 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 + } + } + + // Update filepicker m.filepicker, cmd = m.filepicker.Update(msg) if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect { + // In context mode, filter out media files + if m.IsContextMode && 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(2*time.Second)) + } 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(2*time.Second)) } - 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) + } + + // Show filter input beneath the file listing + filterInputView := m.filterInput.View() + + // Join file picker view and filter input vertically + return lipgloss.JoinVertical( + lipgloss.Left, + filePickerView, + filterInputView, + ) +} + +// filterFilePickerView returns a filtered view of the file picker +func (m FilePicker) filterFilePickerView(filterText string) string { + // Get the current directory from the file picker + currentDir := m.filepicker.CurrentDirectory + + // Read the directory contents + 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 := formatSize(size) + + lines = append(lines, name+" "+sizeStr) + } + + return strings.Join(lines, "\n") +} + +// 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]) } func (m *FilePicker) SetSize(w, h int) { @@ -104,3 +292,73 @@ func (m *FilePicker) SetSize(w, h int) { m.filepicker.SetHeight(h) } } + +func isMediaFile(path string) bool { + for _, ext := range util.MediaExtensions { + if strings.HasSuffix(strings.ToLower(path), ext) { + return true + } + } + return false +} + +// getFilePreviewContent reads and returns the content of a file for preview +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]" + } + + // 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) > 10000 { + contentStr = contentStr[:10000] + "\n... (truncated)" + } + + return contentStr +} + +// GetPreviewView returns the preview pane view for the currently selected file +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 "Error: " + err.Error() + } + if info.IsDir() { + return "[Directory Preview]\n\n" + m.previewFile + } + + // Split content into lines + lines := strings.Split(m.previewContent, "\n") + + // Limit to available height + if len(lines) > height { + lines = lines[:height] + } + + // Add header + header := "[File Preview: " + m.previewFile + "]" + previewLines := append([]string{header, ""}, lines...) + + return strings.Join(previewLines, "\n") +} + +// GetPreviewFile returns the currently selected file for preview +func (m FilePicker) GetPreviewFile() string { + return m.previewFile +} diff --git a/panes/chat-pane.go b/panes/chat-pane.go index 8cf75a4..2e24cad 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+l"), + key.WithHelp("ctrl+l", "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 cafab90..467b2ad 100644 --- a/panes/prompt-pane.go +++ b/panes/prompt-pane.go @@ -76,7 +76,6 @@ type PromptPane struct { input textinput.Model textEditor textarea.Model filePicker components.FilePicker - contextPicker components.ContextPicker inputContainer lipgloss.Style inputMode util.PrompInputMode colors util.SchemeColors @@ -166,7 +165,6 @@ func (p PromptPane) Update(msg tea.Msg) (PromptPane, tea.Cmd) { cmds = append(cmds, p.processTextInputUpdates(msg)) cmds = append(cmds, p.processFilePickerUpdates(msg)) - cmds = append(cmds, p.processContextPickerUpdates(msg)) p.handlePlaceholder() @@ -231,7 +229,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) } @@ -303,7 +303,7 @@ func (p *PromptPane) keyEnter() tea.Cmd { return nil } - if p.viewMode == util.FilePickerMode || p.viewMode == util.ContextPickerMode { + if p.viewMode == util.FilePickerMode { return nil } @@ -414,73 +414,83 @@ func (p *PromptPane) processFilePickerUpdates(msg tea.Msg) tea.Cmd { var cmd tea.Cmd var cmds []tea.Cmd - 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, - }) - - cmds = append(cmds, util.SendViewModeChangedMsg(p.filePicker.PrevView)) - p.filePicker.SelectedFile = "" - } else { - p.filePicker, cmd = p.filePicker.Update(msg) - cmds = append(cmds, 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 { - cmds = append(cmds, util.SendViewModeChangedMsg(util.NormalMode)) } - return tea.Batch(cmds...) -} - -func (p *PromptPane) processContextPickerUpdates(msg tea.Msg) tea.Cmd { - var cmd tea.Cmd - var cmds []tea.Cmd - - if p.isFocused && p.viewMode == util.ContextPickerMode { - if p.contextPicker.SelectedPath != "" { - // Create a context chip from the selected path - selectedPath := p.contextPicker.SelectedPath + if p.isFocused && p.viewMode == util.FilePickerMode { + if p.filePicker.SelectedFile != "" { + selectedPath := p.filePicker.SelectedFile selectedPath = filepath.Clean(selectedPath) selectedPath = strings.ReplaceAll(selectedPath, `\ `, " ") - // Get file info - info, err := os.Stat(selectedPath) - if err != nil { - util.Slog.Error("os.Stat failed for context chip", "path", selectedPath, "error", err.Error()) + 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(), + Type: "file", + } + + if info.IsDir() { + chip.Type = "folder" + chip.FileCount = 0 + } + + p.contextChips = append(p.contextChips, chip) + } else { + util.Slog.Debug("Skipping duplicate context chip", "path", selectedPath) + } + } } else { - chip := util.FileContextChip{ - Path: selectedPath, - Name: filepath.Base(selectedPath), - IsFolder: info.IsDir(), - Size: info.Size(), - Type: "file", + // Add as image attachment + // Check for duplicates before adding + isDuplicate := false + for _, existingAttachment := range p.attachments { + if existingAttachment.Path == selectedPath { + isDuplicate = true + break + } } - if info.IsDir() { - chip.Type = "folder" - // Count files in folder (will be updated when reading) - chip.FileCount = 0 + if !isDuplicate { + p.attachments = append(p.attachments, util.Attachment{ + Type: "img", + Path: selectedPath, + }) + } else { + util.Slog.Debug("Skipping duplicate image attachment", "path", selectedPath) } - - p.contextChips = append(p.contextChips, chip) } - cmds = append(cmds, util.SendViewModeChangedMsg(p.contextPicker.PrevView)) - p.contextPicker.SelectedPath = "" + cmds = append(cmds, util.SendViewModeChangedMsg(p.filePicker.PrevView)) + p.filePicker.SelectedFile = "" } else { - p.contextPicker, cmd = p.contextPicker.Update(msg) + p.filePicker, cmd = p.filePicker.Update(msg) cmds = append(cmds, cmd) } - } - if !p.isFocused && p.viewMode == util.ContextPickerMode { + } + if !p.isFocused && p.viewMode == util.FilePickerMode { cmds = append(cmds, util.SendViewModeChangedMsg(util.NormalMode)) } @@ -494,8 +504,6 @@ func (p *PromptPane) processTextInputUpdates(msg tea.Msg) tea.Cmd { switch p.viewMode { case util.FilePickerMode: break - case util.ContextPickerMode: - break case util.TextEditMode: p.textEditor, cmd = p.textEditor.Update(msg) default: @@ -504,7 +512,9 @@ func (p *PromptPane) processTextInputUpdates(msg tea.Msg) tea.Cmd { // Only trigger if at beginning of input or after space currentValue := p.input.Value() if len(currentValue) == 0 || currentValue[len(currentValue)-1] == ' ' { - return util.SendViewModeChangedMsg(util.ContextPickerMode) + // Set IsContextMode to true and open file picker + p.filePicker.IsContextMode = true + return util.SendViewModeChangedWithContextMsg(util.FilePickerMode, true) } } @@ -552,8 +562,6 @@ func (p *PromptPane) handleWindowSizeMsg(msg tea.WindowSizeMsg) tea.Cmd { case util.FilePickerMode: p.filePicker.SetSize(w, h) - case util.ContextPickerMode: - p.contextPicker.SetSize(w, h) case util.TextEditMode: p.textEditor.SetHeight(h) p.textEditor.SetWidth(w) @@ -583,9 +591,6 @@ func (p *PromptPane) handleViewModeChange(msg util.ViewModeChanged) tea.Cmd { case util.FilePickerMode: cmd = p.openFilePicker(prevMode, currentInput) - case util.ContextPickerMode: - cmd = p.openContextPicker(prevMode, currentInput) - default: cmd = p.openInputField(prevMode, currentInput) } @@ -634,26 +639,11 @@ 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) + p.filePicker = components.NewFilePicker(previousViewMode, currentInput, p.colors, p.filePicker.IsContextMode) p.filePicker.SetSize(w, h) return p.filePicker.Init() } -func (p *PromptPane) openContextPicker(previousViewMode util.ViewMode, currentInput string) tea.Cmd { - w, h := util.CalcPromptPaneSize(p.terminalWidth, p.terminalHeight, p.viewMode) - // Get config values for context picker - cfg, ok := config.FromContext(p.mainCtx) - showIcons := true - maxDepth := 2 - if ok { - showIcons = cfg.GetShowContextIcons() - maxDepth = cfg.GetContextMaxDepth() - } - p.contextPicker = components.NewContextPicker(previousViewMode, currentInput, p.colors, showIcons, maxDepth) - p.contextPicker.SetSize(w, h) - return nil -} - func (p *PromptPane) openTextEditor(content string, op util.Operation, isFocused bool) tea.Cmd { p.operation = op @@ -756,10 +746,6 @@ func (p PromptPane) AllowFocusChange(isMouseEvent bool) bool { return false } - if p.isFocused && p.viewMode == util.ContextPickerMode { - return false - } - return true } @@ -775,8 +761,6 @@ func (p PromptPane) View() string { switch p.viewMode { case util.FilePickerMode: content = p.filePicker.View() - case util.ContextPickerMode: - content = p.contextPicker.View() case util.TextEditMode: content = p.textEditor.View() default: @@ -814,3 +798,33 @@ 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() +} + +// 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/formatter.go b/util/formatter.go index 800d2af..aebf9d3 100644 --- a/util/formatter.go +++ b/util/formatter.go @@ -18,6 +18,7 @@ func GetMessagesAsPrettyString( colors SchemeColors, isQuickChat bool, settings Settings, + showContextContent bool, ) string { var messages string @@ -27,7 +28,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 +51,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 +60,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 +78,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 +102,26 @@ func RenderUserMessage(userMessage LocalStoreMessage, width int, colors SchemeCo msg += attachments } + // Render context chips + if len(userMessage.ContextChips) != 0 { + if showContextContent && userMessage.ContextContent != "" { + // Show full context content + msg += "\n *Context Content:* \n" + msg += userMessage.ContextContent + } 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(). diff --git a/util/shared-events.go b/util/shared-events.go index 95c56a2..c00aea5 100644 --- a/util/shared-events.go +++ b/util/shared-events.go @@ -46,7 +46,6 @@ const ( TextEditMode NormalMode FilePickerMode - ContextPickerMode ) type Operation int @@ -149,7 +148,7 @@ func SendPromptReadyWithContextMsg(prompt string, attachments []Attachment, cont type ContextChipsProcessed struct { Prompt string Attachments []Attachment - ContextContent string + ContextContent string Errors []string // Errors that occurred during processing } @@ -205,12 +204,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} } } @@ -268,3 +275,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 84bf215..85055dd 100644 --- a/util/shared-types.go +++ b/util/shared-types.go @@ -16,12 +16,14 @@ 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"` } type Attachment struct { @@ -115,12 +117,12 @@ func WriteToResponseChannel(ctx context.Context, ch chan<- ProcessApiCompletionR // FileContextChip represents a selected file/folder context in the prompt type FileContextChip struct { - Path string - Name string - IsFolder bool - Size int64 - FileCount int // Number of files if folder - Type string // "file" or "folder" + Path string + Name string + IsFolder bool + Size int64 + FileCount int // Number of files if folder + Type string // "file" or "folder" } // MediaExtensions contains file extensions to exclude from context import diff --git a/views/main-view.go b/views/main-view.go index 3c8eb99..510bad0 100644 --- a/views/main-view.go +++ b/views/main-view.go @@ -81,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 @@ -152,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, @@ -165,6 +169,8 @@ func NewMainView(db *sql.DB, ctx context.Context) MainView { flags: *flags, context: ctx, initialPrompt: flags.InitialPrompt, + showContextContent: false, + pendingContextChips: []util.FileContextChip{}, } } @@ -221,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) { @@ -359,6 +366,8 @@ func (m MainView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // 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() @@ -369,9 +378,10 @@ func (m MainView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -384,6 +394,7 @@ func (m MainView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -392,30 +403,41 @@ func (m MainView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.sessionOrchestrator.ArrayOfMessages = append( m.sessionOrchestrator.ArrayOfMessages, util.LocalStoreMessage{ - Role: "user", - Content: msg.Prompt + contextContent, - Attachments: msg.Attachments, + Role: "user", + Content: msg.Prompt, // Don't append context content here for display + Attachments: msg.Attachments, + ContextChips: m.pendingContextChips, + ContextContent: contextContent, // Store formatted content separately }) + // 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 @@ -577,8 +599,12 @@ func (m MainView) View() string { mainView = m.chatPane.DisplayError(m.error.Message) } + // In FilePickerMode, render file picker in place of chat pane + if m.viewMode == util.FilePickerMode { + mainView = m.promptPane.GetFilePickerView() + } + // Conditionally render windowViews based on view mode - // In ContextPickerMode or FilePickerMode, don't render chat pane to avoid phantom pane if m.viewMode == util.NormalMode { windowViews = lipgloss.NewStyle(). Align(lipgloss.Right, lipgloss.Right). @@ -589,10 +615,36 @@ func (m MainView) View() string { settingsAndSessionPanes, ), ) - } else if m.viewMode == util.ContextPickerMode || m.viewMode == util.FilePickerMode { - // In ContextPickerMode or FilePickerMode, render empty windowViews - // This prevents the chat pane from being rendered and creating a clickable zone - windowViews = "" + } 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 + windowViews = lipgloss.NewStyle(). + Align(lipgloss.Right, lipgloss.Right). + Render( + lipgloss.JoinHorizontal( + lipgloss.Top, + mainView, + previewView, + ), + ) + } 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 @@ -603,6 +655,22 @@ func (m MainView) View() string { 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() return zone.Scan(lipgloss.NewStyle().Render( From d60b59265299143478d148aeb3f3eefcfa93089f Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Sat, 14 Feb 2026 23:11:29 +0700 Subject: [PATCH 09/25] chore: use decodeRuneInString to account for multi-byte char and caching --- clients/gemini.go | 8 +++---- clients/openai.go | 8 +++---- clients/openrouter.go | 8 +++---- components/file-picker.go | 46 ++++++++++++++++++++++++--------------- panes/prompt-pane.go | 19 ++++++++++++++-- util/shared-types.go | 11 +++++----- 6 files changed, 62 insertions(+), 38 deletions(-) diff --git a/clients/gemini.go b/clients/gemini.go index be9a282..59ed875 100644 --- a/clients/gemini.go +++ b/clients/gemini.go @@ -442,13 +442,13 @@ func buildChatHistory(msgs []util.LocalStoreMessage, includeReasoning bool) ([]* messageContent += singleMessage.Content } - // FIX: Include ContextContent in the message sent to LLM if singleMessage.ContextContent != "" { - util.Slog.Debug("Gemini: Including ContextContent in message (FIXED)", + util.Slog.Debug("Gemini: Including ContextContent in message", "contextContentLength", len(singleMessage.ContextContent)) + if messageContent != "" { + messageContent += "\n\n" + } messageContent += singleMessage.ContextContent - } else { - util.Slog.Debug("Gemini: No ContextContent in message") } message := genai.Content{ diff --git a/clients/openai.go b/clients/openai.go index fa62b46..63a74ab 100644 --- a/clients/openai.go +++ b/clients/openai.go @@ -270,13 +270,13 @@ func (c OpenAiClient) constructCompletionRequestPayload( messageContent += singleMessage.Content } - // FIX: Include ContextContent in the message sent to LLM if singleMessage.ContextContent != "" { - util.Slog.Debug("OpenAI: Including ContextContent in message (FIXED)", + util.Slog.Debug("OpenAI: Including ContextContent in message", "contextContentLength", len(singleMessage.ContextContent)) + if messageContent != "" { + messageContent += "\n\n" + } messageContent += singleMessage.ContextContent - } else { - util.Slog.Debug("OpenAI: No ContextContent in message") } if messageContent != "" { diff --git a/clients/openrouter.go b/clients/openrouter.go index 1f6ed86..6b96c24 100644 --- a/clients/openrouter.go +++ b/clients/openrouter.go @@ -267,13 +267,13 @@ func setRequestContext( messageContent += singleMessage.Content } - // FIX: Include ContextContent in the message sent to LLM if singleMessage.ContextContent != "" { - util.Slog.Debug("OpenRouter: Including ContextContent in message (FIXED)", + util.Slog.Debug("OpenRouter: Including ContextContent in message", "contextContentLength", len(singleMessage.ContextContent)) + if messageContent != "" { + messageContent += "\n\n" + } messageContent += singleMessage.ContextContent - } else { - util.Slog.Debug("OpenRouter: No ContextContent in message") } if len(singleMessage.ToolCalls) > 0 { diff --git a/components/file-picker.go b/components/file-picker.go index 2cd2b5a..6dee3de 100644 --- a/components/file-picker.go +++ b/components/file-picker.go @@ -33,6 +33,9 @@ type FilePicker struct { previewFile string // Preview content previewContent string + // Cached directory entries to avoid excessive I/O + cachedDirEntries []os.DirEntry + cachedDirPath string } func NewFilePicker( @@ -118,17 +121,6 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { return m, nil } - // Track filter input focus state by monitoring key events - // If user types a printable character, they're likely in filter input - if len(keyStr) == 1 && keyStr >= " " && keyStr <= "~" { - // Printable character typed, user is in filter input - m.filterInputFocused = true - } else if keyStr == "enter" || keyStr == "tab" { - // Enter or tab pressed, blur filter input - m.filterInputFocused = false - m.filterInput.Blur() - } - switch keyStr { case "esc": // Two-stage Esc behavior: @@ -146,9 +138,13 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { return m, util.SendViewModeChangedMsg(m.PrevView) } case "q": - // 'q' always closes the picker immediately - m.quitting = true - return m, util.SendViewModeChangedMsg(m.PrevView) + // Only close picker if filter input is not focused + if !m.filterInputFocused { + m.quitting = true + return m, util.SendViewModeChangedMsg(m.PrevView) + } + // Consume "q" when filter is focused + return m, nil } case clearErrorMsg: @@ -171,6 +167,15 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { // Update filepicker m.filepicker, cmd = m.filepicker.Update(msg) + // 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 && isMediaFile(path) { @@ -225,10 +230,15 @@ func (m FilePicker) filterFilePickerView(filterText string) string { // Get the current directory from the file picker currentDir := m.filepicker.CurrentDirectory - // Read the directory contents - entries, err := os.ReadDir(currentDir) - if err != nil { - return "Error reading directory: " + err.Error() + // 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 diff --git a/panes/prompt-pane.go b/panes/prompt-pane.go index 467b2ad..ffa2e5f 100644 --- a/panes/prompt-pane.go +++ b/panes/prompt-pane.go @@ -6,6 +6,7 @@ import ( "path/filepath" "regexp" "strings" + "unicode/utf8" "github.com/BalanceBalls/nekot/components" "github.com/BalanceBalls/nekot/config" @@ -448,11 +449,9 @@ func (p *PromptPane) processFilePickerUpdates(msg tea.Msg) tea.Cmd { Name: filepath.Base(selectedPath), IsFolder: info.IsDir(), Size: info.Size(), - Type: "file", } if info.IsDir() { - chip.Type = "folder" chip.FileCount = 0 } @@ -505,6 +504,22 @@ 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 diff --git a/util/shared-types.go b/util/shared-types.go index 85055dd..b0dc8c3 100644 --- a/util/shared-types.go +++ b/util/shared-types.go @@ -117,12 +117,11 @@ func WriteToResponseChannel(ctx context.Context, ch chan<- ProcessApiCompletionR // FileContextChip represents a selected file/folder context in the prompt type FileContextChip struct { - Path string - Name string - IsFolder bool - Size int64 - FileCount int // Number of files if folder - Type string // "file" or "folder" + Path string `json:"path"` + Name string `json:"name"` + IsFolder bool `json:"is_folder"` + Size int64 `json:"size"` + FileCount int `json:"file_count"` } // MediaExtensions contains file extensions to exclude from context import From 2ea354fe9e09214730368c27a65c3c0eab6e50e0 Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Sat, 14 Feb 2026 23:15:05 +0700 Subject: [PATCH 10/25] fix: allow literal 'q' to be typed in filter input mode --- components/file-picker.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/components/file-picker.go b/components/file-picker.go index 6dee3de..34f6ab7 100644 --- a/components/file-picker.go +++ b/components/file-picker.go @@ -137,14 +137,6 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { m.quitting = true return m, util.SendViewModeChangedMsg(m.PrevView) } - case "q": - // Only close picker if filter input is not focused - if !m.filterInputFocused { - m.quitting = true - return m, util.SendViewModeChangedMsg(m.PrevView) - } - // Consume "q" when filter is focused - return m, nil } case clearErrorMsg: From c3593481e339f448c32b518ee42e1c02308e19b9 Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Sun, 15 Feb 2026 00:08:43 +0700 Subject: [PATCH 11/25] chore: added preview right bar and recursive fuzzing --- README.md | 12 +- components/file-picker.go | 391 +++++++++++++++++++++++++++++++++++++- panes/prompt-pane.go | 16 +- views/main-view.go | 22 ++- 4 files changed, 429 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 435dd02..cada95e 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,9 @@ 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, + "showContextIcons": true } ``` @@ -152,6 +154,8 @@ We provide a `config.json` file within your directory for easy access to essenti - `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. + - `showContextIcons`: Controls whether to show file type icons in the context picker. Default is `true`. ### Providers @@ -253,6 +257,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/components/file-picker.go b/components/file-picker.go index 34f6ab7..13c6246 100644 --- a/components/file-picker.go +++ b/components/file-picker.go @@ -3,9 +3,13 @@ package components import ( "errors" "fmt" + "io/fs" "os" + "path/filepath" + "regexp" "strings" "time" + "unicode/utf8" "github.com/BalanceBalls/nekot/util" "github.com/charmbracelet/bubbles/filepicker" @@ -14,6 +18,14 @@ import ( "github.com/charmbracelet/lipgloss" ) +// SearchResult represents a file found during recursive search +type SearchResult struct { + Path string + RelPath string // Relative to current directory + IsDir bool + Size int64 +} + type FilePicker struct { SelectedFile string PrevView util.ViewMode @@ -29,6 +41,12 @@ type FilePicker struct { filterInput textinput.Model // Filtered files list filteredFiles []os.DirEntry + // Search results for recursive fuzzy matching + searchResults []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 @@ -36,6 +54,10 @@ type FilePicker struct { // Cached directory entries to avoid excessive I/O cachedDirEntries []os.DirEntry cachedDirPath string + // Last rendered view for tracking selection changes + lastRenderedView string + // Terminal width for line truncation in preview + terminalWidth int } func NewFilePicker( @@ -43,6 +65,7 @@ func NewFilePicker( prevInput string, colors util.SchemeColors, isContextMode bool, + searchDepth int, ) FilePicker { fp := filepicker.New() @@ -89,10 +112,44 @@ func NewFilePicker( filterInput: filterInput, filterInputFocused: false, filteredFiles: []os.DirEntry{}, + searchResults: []SearchResult{}, + searchDepth: searchDepth, + colors: colors, } return filePicker } +func isTextFile(path string) bool { + // Check extension against known text/code extensions + ext := strings.ToLower(filepath.Ext(path)) + for _, textExt := range util.CodeExtensions { + if ext == textExt { + return true + } + } + + // Additional common text extensions + textExtensions := []string{".txt", ".md", ".markdown", ".rst", ".log", ".csv", ".json", ".xml", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf"} + for _, textExt := range textExtensions { + if ext == textExt { + return true + } + } + + // Try to read a small portion to check if it's valid UTF-8 + content, err := os.ReadFile(path) + if err != nil { + return false + } + + // Check first 1024 bytes for UTF-8 validity + checkSize := 1024 + if len(content) < checkSize { + checkSize = len(content) + } + return utf8.Valid(content[:checkSize]) +} + type clearErrorMsg struct{} func clearErrorAfter(t time.Duration) tea.Cmd { @@ -159,6 +216,14 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { // Update filepicker m.filepicker, cmd = m.filepicker.Update(msg) + // Track selection changes for preview update + currentView := m.filepicker.View() + if currentView != m.lastRenderedView { + m.lastRenderedView = currentView + // Try to extract currently selected file from view + m.updatePreviewFromView(currentView) + } + // Refresh cache if directory changed if m.cachedDirPath != m.filepicker.CurrentDirectory { entries, err := os.ReadDir(m.filepicker.CurrentDirectory) @@ -217,11 +282,137 @@ func (m FilePicker) View() string { ) } +// 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 +} + +// GetFilterInputView returns the filter input view +func (m FilePicker) GetFilterInputView() string { + return m.filterInput.View() +} + +// recursiveSearch performs a recursive search for files matching the filter text +// Searches up to maxDepth levels deep from the current directory +func (m FilePicker) recursiveSearch(filterText string, maxDepth int) []SearchResult { + var results []SearchResult + currentDir := m.filepicker.CurrentDirectory + + _ = filepath.WalkDir(currentDir, 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 == currentDir { + return nil + } + + // Calculate depth + relPath, relErr := filepath.Rel(currentDir, 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 + } + + // Skip media files in context mode + if m.IsContextMode && isMediaFile(filePath) { + return nil + } + + // Check if the entry name contains the filter text (case-insensitive) + if strings.Contains(strings.ToLower(baseName), filterText) { + info, err := d.Info() + if err != nil { + return nil + } + + results = append(results, SearchResult{ + Path: filePath, + RelPath: relPath, + IsDir: d.IsDir(), + Size: info.Size(), + }) + } + + return nil + }) + + return results +} + // filterFilePickerView returns a filtered view of the file picker +// Uses recursive search when filter is active in context mode func (m FilePicker) filterFilePickerView(filterText string) string { // Get the current directory from the file picker currentDir := m.filepicker.CurrentDirectory + // In context mode, use recursive search + if m.IsContextMode { + // Perform recursive search + m.searchResults = m.recursiveSearch(filterText, m.searchDepth) + + // 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 _, result := range m.searchResults { + // Format the entry line with relative path + name := result.RelPath + if result.IsDir { + name += "/" + } + + // Add size info + sizeStr := formatSize(result.Size) + + // Add indentation based on depth for visual hierarchy + depth := strings.Count(result.RelPath, string(filepath.Separator)) + indent := strings.Repeat(" ", depth) + + lines = append(lines, indent+name+" "+sizeStr) + } + + 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 { @@ -292,6 +483,7 @@ func formatSize(size int64) string { func (m *FilePicker) SetSize(w, h int) { if w > 2 && h > 2 { m.filepicker.SetHeight(h) + m.terminalWidth = w } } @@ -305,6 +497,7 @@ func isMediaFile(path string) bool { } // 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) @@ -315,6 +508,11 @@ func (m FilePicker) getFilePreviewContent(path string) string { return "[Directory]" } + // Check if it's a text file + if !isTextFile(path) { + return "[Binary file - preview not available]" + } + // Read file content content, err := os.ReadFile(path) if err != nil { @@ -331,6 +529,8 @@ func (m FilePicker) getFilePreviewContent(path string) string { } // 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 func (m FilePicker) GetPreviewView(height int) string { if !m.IsContextMode || m.previewFile == "" { return "" @@ -339,27 +539,202 @@ func (m FilePicker) GetPreviewView(height int) string { // Check if it's a directory info, err := os.Stat(m.previewFile) if err != nil { - return "Error: " + err.Error() + return lipgloss.NewStyle(). + Foreground(m.colors.ErrorColor). + Render("Error: " + err.Error()) } if info.IsDir() { - return "[Directory Preview]\n\n" + m.previewFile + 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 it's a text file - don't show preview for binary files + if !isTextFile(m.previewFile) { + 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) } // Split content into lines lines := strings.Split(m.previewContent, "\n") - // Limit to available height - if len(lines) > height { - lines = lines[:height] + // 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) + // We need to account for line numbers and borders + // Line format: "NNN │ content" where NNN is line number + // Reserve space for line numbers, separator, and padding + maxLineWidth := (m.terminalWidth / 2) - lineNumWidth - 5 // 5 for " │ " and padding + + // truncate detection + hasLongLines := false + for _, line := range lines { + // Count visible characters (excluding ANSI codes) + visibleLen := utf8.RuneCountInString(stripANSI(line)) + if visibleLen > maxLineWidth { + hasLongLines = true + break + } } - // Add header - header := "[File Preview: " + m.previewFile + "]" - previewLines := append([]string{header, ""}, lines...) + // If any line is too long, truncate all lines to max width + if hasLongLines { + for i := range lines { + visibleLen := utf8.RuneCountInString(stripANSI(lines[i])) + if visibleLen > maxLineWidth { + // Truncate line and add ellipsis + // We need to preserve ANSI codes while truncating + truncated := 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 := 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)) + } return strings.Join(previewLines, "\n") } +// 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 := stripANSI(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 +} + +// stripANSI removes ANSI escape codes from a string +func stripANSI(s string) string { + // ANSI escape code pattern: \x1b[...m + ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`) + return ansiRegex.ReplaceAllString(s, "") +} + +// 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 + } + + util.Slog.Debug("FilePicker: updatePreviewFromView called", "view_length", len(view)) + util.Slog.Debug("FilePicker: CurrentDirectory", "path", m.filepicker.CurrentDirectory) + util.Slog.Debug("FilePicker: searchResults count", "count", len(m.searchResults)) + + // Parse the view to find the currently selected file (marked with cursor) + lines := strings.Split(view, "\n") + for i, line := range lines { + // Look for cursor indicator (filepicker uses > for cursor) + if strings.Contains(line, ">") { + util.Slog.Debug("FilePicker: Found cursor line", "line_index", i, "line_content", line) + + // Strip ANSI escape codes + cleanLine := stripANSI(line) + util.Slog.Debug("FilePicker: Cleaned line (no ANSI)", "line_content", cleanLine) + + // 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]) + util.Slog.Debug("FilePicker: Extracted rest after >", "rest", rest) + + // 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] + util.Slog.Debug("FilePicker: Extracted filename", "filename", fileName) + + // Get full path by combining with current directory + if !strings.HasPrefix(fileName, "/") && !strings.HasPrefix(fileName, "~") { + // Relative path + fullPath := filepath.Join(m.filepicker.CurrentDirectory, fileName) + util.Slog.Debug("FilePicker: Constructed full path", "full_path", fullPath) + + // Check if file exists + if _, err := os.Stat(fullPath); err != nil { + util.Slog.Error("FilePicker: File does not exist", "path", fullPath, "error", err) + } else { + util.Slog.Debug("FilePicker: File exists", "path", fullPath) + } + + // Only update if different from current preview + if fullPath != m.previewFile { + util.Slog.Debug("FilePicker: Updating preview from navigation", "path", fullPath) + m.previewFile = fullPath + m.previewContent = m.getFilePreviewContent(fullPath) + } else { + util.Slog.Debug("FilePicker: Preview file unchanged", "path", fullPath) + } + } else { + util.Slog.Debug("FilePicker: Path is absolute or home, using as-is", "path", fileName) + 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 diff --git a/panes/prompt-pane.go b/panes/prompt-pane.go index ffa2e5f..a901e65 100644 --- a/panes/prompt-pane.go +++ b/panes/prompt-pane.go @@ -81,6 +81,7 @@ type PromptPane struct { inputMode util.PrompInputMode colors util.SchemeColors keys keyMap + config config.Config pendingInsert string attachments []util.Attachment @@ -143,6 +144,7 @@ func NewPromptPane(ctx context.Context) PromptPane { keys: defaultKeyMap, viewMode: util.NormalMode, colors: colors, + config: *config, input: input, textEditor: textEditor, inputContainer: container, @@ -654,7 +656,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, p.filePicker.IsContextMode) + 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() } @@ -819,6 +822,17 @@ 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() +} + // GetInputContainerStyle returns the input container style for wrapping the file picker func (p *PromptPane) GetInputContainerStyle() lipgloss.Style { return p.inputContainer diff --git a/views/main-view.go b/views/main-view.go index 510bad0..50ca9f5 100644 --- a/views/main-view.go +++ b/views/main-view.go @@ -601,7 +601,12 @@ func (m MainView) View() string { // In FilePickerMode, render file picker in place of chat pane if m.viewMode == util.FilePickerMode { - mainView = m.promptPane.GetFilePickerView() + // In context mode, use view without filter (filter is shown below preview) + if m.promptPane.GetFilePickerIsContextMode() { + mainView = m.promptPane.GetFilePickerViewWithoutFilter() + } else { + mainView = m.promptPane.GetFilePickerView() + } } // Conditionally render windowViews based on view mode @@ -624,13 +629,26 @@ func (m MainView) View() string { if previewView != "" { // Show file picker and preview side by side + // Filter input is shown below the preview + filterInputView := m.promptPane.GetFilePickerFilterInputView() + previewWithFilter := lipgloss.JoinVertical( + lipgloss.Left, + previewView, + filterInputView, + ) + // 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, - previewView, + border, + previewWithFilter, ), ) } else { From e47260b9886125235758d0e32ea2df2259027a37 Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Sun, 15 Feb 2026 00:24:51 +0700 Subject: [PATCH 12/25] chore: fixed folder preview with keybinding showing recursive content --- panes/prompt-pane.go | 9 ++++++++- util/file-reader.go | 33 +++++++++++++++++++++++++++++++++ util/formatter.go | 18 ++++++++++++++++-- util/shared-events.go | 4 +++- util/shared-types.go | 12 +++++++----- views/main-view.go | 24 ++++++++++++++++++++---- 6 files changed, 87 insertions(+), 13 deletions(-) diff --git a/panes/prompt-pane.go b/panes/prompt-pane.go index a901e65..8305176 100644 --- a/panes/prompt-pane.go +++ b/panes/prompt-pane.go @@ -454,7 +454,14 @@ func (p *PromptPane) processFilePickerUpdates(msg tea.Msg) tea.Cmd { } if info.IsDir() { - chip.FileCount = 0 + // 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) diff --git a/util/file-reader.go b/util/file-reader.go index e003605..b7998da 100644 --- a/util/file-reader.go +++ b/util/file-reader.go @@ -225,3 +225,36 @@ func CountFilesInFolder(path string, maxDepth int) (int, error) { return count, 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 aebf9d3..8fbbacd 100644 --- a/util/formatter.go +++ b/util/formatter.go @@ -105,9 +105,23 @@ func RenderUserMessage(userMessage LocalStoreMessage, width int, colors SchemeCo // Render context chips if len(userMessage.ContextChips) != 0 { if showContextContent && userMessage.ContextContent != "" { - // Show full context content + // Show folder entries for folders, full content for files msg += "\n *Context Content:* \n" - msg += userMessage.ContextContent + 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" diff --git a/util/shared-events.go b/util/shared-events.go index c00aea5..2b0be58 100644 --- a/util/shared-events.go +++ b/util/shared-events.go @@ -149,7 +149,9 @@ type ContextChipsProcessed struct { Prompt string Attachments []Attachment ContextContent string - Errors []string // Errors that occurred during processing + 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 { diff --git a/util/shared-types.go b/util/shared-types.go index b0dc8c3..9e28b24 100644 --- a/util/shared-types.go +++ b/util/shared-types.go @@ -24,6 +24,7 @@ type LocalStoreMessage struct { 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 { @@ -117,11 +118,12 @@ func WriteToResponseChannel(ctx context.Context, ch chan<- ProcessApiCompletionR // 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"` + 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 diff --git a/views/main-view.go b/views/main-view.go index 50ca9f5..2dd7fc0 100644 --- a/views/main-view.go +++ b/views/main-view.go @@ -406,9 +406,12 @@ func (m MainView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Role: "user", Content: msg.Prompt, // Don't append context content here for display Attachments: msg.Attachments, - ContextChips: m.pendingContextChips, - ContextContent: contextContent, // Store formatted content separately + 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 @@ -747,12 +750,14 @@ func mapAttachmentType(attachmentType string) string { 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 _, chip := range chips { + for i, chip := range chips { + updatedChips[i] = chip // Copy the chip if chip.IsFolder { // Read folder contents - util.Slog.Debug("reading folder context", "path", chip.Path) contents, filePaths, err := util.ReadFolderContents(chip.Path, maxDepth) if err != nil { errMsg := fmt.Sprintf("Failed to read folder %s: %s", chip.Path, err.Error()) @@ -764,6 +769,14 @@ func (m MainView) makeProcessContextChipsCmd(prompt string, attachments []util.A // 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) @@ -778,6 +791,7 @@ func (m MainView) makeProcessContextChipsCmd(prompt string, attachments []util.A // Format file content formatted := util.FormatFileContent(chip.Path, content) contextContent.WriteString(formatted) + fileContents.WriteString(formatted) // Also add to fileContents for display } } @@ -786,6 +800,8 @@ func (m MainView) makeProcessContextChipsCmd(prompt string, attachments []util.A Attachments: attachments, ContextContent: contextContent.String(), Errors: errors, + ContextChips: updatedChips, + FileContents: fileContents.String(), } } } From bd629f5957246b525c5bb32cf4500400f4e9e422 Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Sun, 15 Feb 2026 10:44:34 +0700 Subject: [PATCH 13/25] fix: preview tab can't be moved with arrows --- components/file-picker.go | 119 +++++++++++++++++++++++++++++++++++++- panes/prompt-pane.go | 1 + views/main-view.go | 7 ++- 3 files changed, 122 insertions(+), 5 deletions(-) diff --git a/components/file-picker.go b/components/file-picker.go index 13c6246..0d8d32b 100644 --- a/components/file-picker.go +++ b/components/file-picker.go @@ -58,6 +58,8 @@ type FilePicker struct { lastRenderedView string // Terminal width for line truncation in preview terminalWidth int + // Selection index for filtered view (when filter is active) + filteredSelectionIndex int } func NewFilePicker( @@ -166,15 +168,35 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: keyStr := msg.String() + keyType := msg.Type // Debug: Log all key events when in FilePickerMode - util.Slog.Debug("FilePicker: Key event received", "keyStr", keyStr, "isContextMode", m.IsContextMode) + util.Slog.Debug("FilePicker: Key event received", + "keyStr", keyStr, + "keyType", keyType.String(), + "isContextMode", m.IsContextMode, + "filterInputFocused", m.filterInputFocused, + "filterValue", m.filterInput.Value(), + "searchResultsCount", len(m.searchResults), + "filteredSelectionIndex", m.filteredSelectionIndex) // Handle Ctrl+/ (or Ctrl+_ on some keyboards) to focus filter input if keyStr == "ctrl+/" || keyStr == "ctrl+_" { util.Slog.Debug("FilePicker: Ctrl+/ detected, focusing filter input") 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) + util.Slog.Debug("FilePicker: Initialized search results on focus", "filterText", filterText, "resultsCount", len(m.searchResults)) + // Reset selection index + if len(m.searchResults) > 0 { + m.filteredSelectionIndex = 0 + } else { + m.filteredSelectionIndex = -1 + } + } return m, nil } @@ -194,6 +216,53 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { 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 + util.Slog.Debug("FilePicker: Selected file from filtered view", "path", selectedResult.Path) + return m, nil + } + case "up", "down": + // Log arrow key events for debugging navigation issues + util.Slog.Debug("FilePicker: Arrow key case HIT", + "keyStr", keyStr, + "filterInputFocused", m.filterInputFocused, + "filterValue", m.filterInput.Value(), + "searchResultsCount", len(m.searchResults), + "willHandle", m.filterInputFocused && len(m.searchResults) > 0) + + // 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 + } + } + util.Slog.Debug("FilePicker: Updated filtered selection", "index", m.filteredSelectionIndex, "total", len(m.searchResults)) + + // 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) + util.Slog.Debug("FilePicker: Updated preview from filtered selection", "path", selectedResult.Path) + } + } + + // Don't pass arrow keys to filter input when navigating filtered results + util.Slog.Debug("FilePicker: Returning early from arrow key handler") + return m, nil + } + util.Slog.Debug("FilePicker: Arrow key not handled, falling through") } case clearErrorMsg: @@ -205,10 +274,34 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { // Update filter input if focused if m.filterInputFocused { + util.Slog.Debug("FilePicker: Updating filter input", "msgType", fmt.Sprintf("%T", msg)) + oldFilterValue := m.filterInput.Value() m.filterInput, filterCmd = m.filterInput.Update(msg) + newFilterValue := m.filterInput.Value() + util.Slog.Debug("FilePicker: Filter input updated", "oldValue", oldFilterValue, "newValue", newFilterValue, "cursorPos", m.filterInput.Cursor) + + // If filter value changed, update search results + if oldFilterValue != newFilterValue { + filterText := strings.ToLower(newFilterValue) + if filterText != "" && m.IsContextMode { + m.searchResults = m.recursiveSearch(filterText, m.searchDepth) + util.Slog.Debug("FilePicker: Updated search results from filter change", "filterText", filterText, "resultsCount", len(m.searchResults)) + // Reset selection index when filter changes + if len(m.searchResults) > 0 { + m.filteredSelectionIndex = 0 + } else { + m.filteredSelectionIndex = -1 + } + } else { + m.searchResults = []SearchResult{} + m.filteredSelectionIndex = -1 + } + } + // 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 { + util.Slog.Debug("FilePicker: Returning early from filter input update (KeyMsg)") return m, filterCmd } } @@ -377,11 +470,25 @@ func (m FilePicker) filterFilePickerView(filterText string) string { // Get the current directory from the file picker currentDir := m.filepicker.CurrentDirectory + util.Slog.Debug("FilePicker: filterFilePickerView called", "filterText", filterText, "isContextMode", m.IsContextMode, "currentDir", currentDir) + // In context mode, use recursive search if m.IsContextMode { // Perform recursive search m.searchResults = m.recursiveSearch(filterText, m.searchDepth) + util.Slog.Debug("FilePicker: Recursive search completed", "filterText", filterText, "resultsCount", len(m.searchResults)) + + // 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() @@ -392,7 +499,7 @@ func (m FilePicker) filterFilePickerView(filterText string) string { lines = append(lines, currentDir) lines = append(lines, "") - for _, result := range m.searchResults { + for i, result := range m.searchResults { // Format the entry line with relative path name := result.RelPath if result.IsDir { @@ -406,7 +513,13 @@ func (m FilePicker) filterFilePickerView(filterText string) string { depth := strings.Count(result.RelPath, string(filepath.Separator)) indent := strings.Repeat(" ", depth) - lines = append(lines, indent+name+" "+sizeStr) + // Add cursor indicator for selected item + prefix := " " + if i == m.filteredSelectionIndex { + prefix = "> " + } + + lines = append(lines, prefix+indent+name+" "+sizeStr) } return strings.Join(lines, "\n") diff --git a/panes/prompt-pane.go b/panes/prompt-pane.go index 8305176..c5e7924 100644 --- a/panes/prompt-pane.go +++ b/panes/prompt-pane.go @@ -481,6 +481,7 @@ func (p *PromptPane) processFilePickerUpdates(msg tea.Msg) tea.Cmd { } 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, diff --git a/views/main-view.go b/views/main-view.go index 2dd7fc0..3056dc3 100644 --- a/views/main-view.go +++ b/views/main-view.go @@ -346,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, @@ -362,6 +364,7 @@ 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 From 0b103d7d9e5196dc84c632bacffb354068583f63 Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Sun, 15 Feb 2026 10:54:49 +0700 Subject: [PATCH 14/25] ui: better filter layout and colorized --- components/file-picker.go | 30 +++++++++++++++++++++++++++--- views/main-view.go | 6 +++--- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/components/file-picker.go b/components/file-picker.go index 0d8d32b..ff69aa6 100644 --- a/components/file-picker.go +++ b/components/file-picker.go @@ -102,7 +102,7 @@ func NewFilePicker( // Initialize filter input filterInput := textinput.New() filterInput.Placeholder = "Filter files..." - filterInput.Prompt = "/" + filterInput.Prompt = "Filter: " filterInput.PromptStyle = lipgloss.NewStyle().Foreground(colors.ActiveTabBorderColor) filterInput.PlaceholderStyle = lipgloss.NewStyle().Faint(true) @@ -513,13 +513,37 @@ func (m FilePicker) filterFilePickerView(filterText string) string { 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 i == m.filteredSelectionIndex { + if isSelected { prefix = "> " } - lines = append(lines, prefix+indent+name+" "+sizeStr) + // 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") diff --git a/views/main-view.go b/views/main-view.go index 3056dc3..5e67b96 100644 --- a/views/main-view.go +++ b/views/main-view.go @@ -607,7 +607,7 @@ func (m MainView) View() string { // 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 below preview) + // In context mode, use view without filter (filter is shown above preview) if m.promptPane.GetFilePickerIsContextMode() { mainView = m.promptPane.GetFilePickerViewWithoutFilter() } else { @@ -635,12 +635,12 @@ func (m MainView) View() string { if previewView != "" { // Show file picker and preview side by side - // Filter input is shown below the preview + // Filter input is shown above the preview filterInputView := m.promptPane.GetFilePickerFilterInputView() previewWithFilter := lipgloss.JoinVertical( lipgloss.Left, - previewView, filterInputView, + previewView, ) // Create a vertical border between file picker and preview colors := m.config.ColorScheme.GetColors() From 17fc2520f61bb9a53f0e9b72414dd2b531aae39b Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Sun, 15 Feb 2026 11:11:14 +0700 Subject: [PATCH 15/25] refactor: support even more text extensions, remove redudant show icon in file picker config --- README.md | 20 +++++++++----------- components/file-picker.go | 3 +-- config/config-handler.go | 15 --------------- config/config.json | 3 +-- util/shared-types.go | 21 ++++++++++++++++++++- 5 files changed, 31 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index cada95e..b666c55 100644 --- a/README.md +++ b/README.md @@ -142,20 +142,18 @@ We provide a `config.json` file within your directory for easy access to essenti "maxAttachmentSizeMb": 3, "includeReasoningTokensInContext": true, "sessionExportDir": "/must/be/absolute/path/to/exports", - "contextMaxDepth": 2, - "showContextIcons": true + "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** - - `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. - - `showContextIcons`: Controls whether to show file type icons in the context picker. Default is `true`. + - `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 diff --git a/components/file-picker.go b/components/file-picker.go index ff69aa6..0feeb24 100644 --- a/components/file-picker.go +++ b/components/file-picker.go @@ -131,8 +131,7 @@ func isTextFile(path string) bool { } // Additional common text extensions - textExtensions := []string{".txt", ".md", ".markdown", ".rst", ".log", ".csv", ".json", ".xml", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf"} - for _, textExt := range textExtensions { + for _, textExt := range util.TextExtensions { if ext == textExt { return true } diff --git a/config/config-handler.go b/config/config-handler.go index 3a226e0..af8b9ec 100644 --- a/config/config-handler.go +++ b/config/config-handler.go @@ -54,7 +54,6 @@ type Config struct { IncludeReasoningTokensInContext *bool `json:"includeReasoningTokensInContext"` SessionExportDir string `json:"sessionExportDir"` ContextMaxDepth *int `json:"contextMaxDepth"` - ShowContextIcons *bool `json:"showContextIcons"` } type StartupFlags struct { @@ -237,12 +236,6 @@ func (c *Config) setDefaults() { defaultDepth := 2 c.ContextMaxDepth = &defaultDepth } - - // Set default show context icons to true - if c.ShowContextIcons == nil { - defaultShowIcons := true - c.ShowContextIcons = &defaultShowIcons - } } // GetContextMaxDepth returns the context max depth, defaulting to 2 if nil @@ -253,14 +246,6 @@ func (c *Config) GetContextMaxDepth() int { return *c.ContextMaxDepth } -// GetShowContextIcons returns whether to show context icons, defaulting to true if nil -func (c *Config) GetShowContextIcons() bool { - if c.ShowContextIcons == nil { - return true - } - return *c.ShowContextIcons -} - func (c *Config) applyFlags(flags StartupFlags) { if flags.Theme != "" { c.ColorScheme = util.ColorScheme(strings.ToLower(flags.Theme)) diff --git a/config/config.json b/config/config.json index 53f2308..e67c548 100644 --- a/config/config.json +++ b/config/config.json @@ -7,6 +7,5 @@ "maxAttachmentSizeMb": 10, "includeReasoningTokensInContext": true, "sessionExportDir": "", - "contextMaxDepth": 2, - "showContextIcons": true + "contextMaxDepth": 2 } diff --git a/util/shared-types.go b/util/shared-types.go index 9e28b24..b0a5714 100644 --- a/util/shared-types.go +++ b/util/shared-types.go @@ -140,5 +140,24 @@ var CodeExtensions = []string{ ".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", ".txt", + ".toml", ".ini", ".cfg", ".conf", ".md", ".markdown", +} + +// TextExtensions that have common text file (used along with the text-based file selector for 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", } From c17ea020bba25febf1a7ce743d39c0475410e889 Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Sun, 15 Feb 2026 16:59:58 +0700 Subject: [PATCH 16/25] chore: switch to ctrl+g due to ctrl+l being conflicted as terminal clear --- panes/chat-pane.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/panes/chat-pane.go b/panes/chat-pane.go index 2e24cad..a1683ac 100644 --- a/panes/chat-pane.go +++ b/panes/chat-pane.go @@ -62,8 +62,8 @@ var defaultChatPaneKeyMap = chatPaneKeyMap{ key.WithHelp("G", "scroll to bottom"), ), toggleContextContent: key.NewBinding( - key.WithKeys("ctrl+l"), - key.WithHelp("ctrl+l", "toggle context content visibility"), + key.WithKeys("ctrl+g"), + key.WithHelp("ctrl+g", "toggle context content visibility"), ), } From 677362b5bbb730d54a9035f781544ee74cdd6cda Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Sun, 15 Feb 2026 17:00:24 +0700 Subject: [PATCH 17/25] chore: removed duplicated extensions from the code and text lists --- util/shared-types.go | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/util/shared-types.go b/util/shared-types.go index b0a5714..37b9d46 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 @@ -135,6 +138,7 @@ var MediaExtensions = []string{ } // 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", @@ -143,7 +147,7 @@ var CodeExtensions = []string{ ".toml", ".ini", ".cfg", ".conf", ".md", ".markdown", } -// TextExtensions that have common text file (used along with the text-based file selector for hinting) +// 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", @@ -161,3 +165,19 @@ var TextExtensions = []string{ ".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 +} From c669fa2805ed22830344aa356b4bf22a65c8c644 Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Sun, 15 Feb 2026 17:01:26 +0700 Subject: [PATCH 18/25] refactor: switch to os.Open and read up to N bytes only --- components/file-picker.go | 40 ++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/components/file-picker.go b/components/file-picker.go index 0feeb24..22e3645 100644 --- a/components/file-picker.go +++ b/components/file-picker.go @@ -3,6 +3,7 @@ package components import ( "errors" "fmt" + "io" "io/fs" "os" "path/filepath" @@ -122,33 +123,38 @@ func NewFilePicker( } func isTextFile(path string) bool { - // Check extension against known text/code extensions - ext := strings.ToLower(filepath.Ext(path)) - for _, textExt := range util.CodeExtensions { - if ext == textExt { - return true - } + // Check extension against known text/code extensions using helper function + ext := filepath.Ext(path) + if util.IsTextOrCodeExtension(ext) { + return true } - // Additional common text extensions - for _, textExt := range util.TextExtensions { - if ext == textExt { - 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 + const maxPreviewFileSize = 1024 * 1024 // 1MB + if fileInfo.Size() > maxPreviewFileSize { + return false } // Try to read a small portion to check if it's valid UTF-8 - content, err := os.ReadFile(path) + // 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() - // Check first 1024 bytes for UTF-8 validity - checkSize := 1024 - if len(content) < checkSize { - checkSize = len(content) + // Read only first 1024 bytes for UTF-8 validity check + buf := make([]byte, 1024) + n, err := file.Read(buf) + if err != nil && err != io.EOF { + return false } - return utf8.Valid(content[:checkSize]) + return utf8.Valid(buf[:n]) } type clearErrorMsg struct{} From 6e53b60d4c55b46c68292e4410d4cb1d4ae67943 Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Sun, 15 Feb 2026 20:48:51 +0700 Subject: [PATCH 19/25] refactor: added debounced when filter, move const to consts.go --- components/file-picker.go | 149 +++++++++++++++++++------------------- util/const.go | 9 +++ 2 files changed, 82 insertions(+), 76 deletions(-) create mode 100644 util/const.go diff --git a/components/file-picker.go b/components/file-picker.go index 22e3645..ae65b81 100644 --- a/components/file-picker.go +++ b/components/file-picker.go @@ -19,6 +19,19 @@ import ( "github.com/charmbracelet/lipgloss" ) +// Debounce message for delayed search +type debounceSearchMsg struct { + filterText string +} + +// File picker constants +const ( + maxPreviewFileSize = 1024 * 1024 // 1MB - max file size for text preview + 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 +) + // SearchResult represents a file found during recursive search type SearchResult struct { Path string @@ -52,6 +65,8 @@ type FilePicker struct { previewFile string // Preview content previewContent string + // Cached preview lines to avoid re-splitting on every render + previewLines []string // Cached directory entries to avoid excessive I/O cachedDirEntries []os.DirEntry cachedDirPath string @@ -61,6 +76,11 @@ type FilePicker struct { 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( @@ -118,6 +138,7 @@ func NewFilePicker( searchResults: []SearchResult{}, searchDepth: searchDepth, colors: colors, + debounceChannel: make(chan string, 1), } return filePicker } @@ -135,7 +156,6 @@ func isTextFile(path string) bool { return false } // Skip files larger than 1MB as they're unlikely to be suitable for quick preview - const maxPreviewFileSize = 1024 * 1024 // 1MB if fileInfo.Size() > maxPreviewFileSize { return false } @@ -149,7 +169,7 @@ func isTextFile(path string) bool { defer file.Close() // Read only first 1024 bytes for UTF-8 validity check - buf := make([]byte, 1024) + buf := make([]byte, utf8CheckBufferSize) n, err := file.Read(buf) if err != nil && err != io.EOF { return false @@ -173,28 +193,15 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: keyStr := msg.String() - keyType := msg.Type - - // Debug: Log all key events when in FilePickerMode - util.Slog.Debug("FilePicker: Key event received", - "keyStr", keyStr, - "keyType", keyType.String(), - "isContextMode", m.IsContextMode, - "filterInputFocused", m.filterInputFocused, - "filterValue", m.filterInput.Value(), - "searchResultsCount", len(m.searchResults), - "filteredSelectionIndex", m.filteredSelectionIndex) // Handle Ctrl+/ (or Ctrl+_ on some keyboards) to focus filter input if keyStr == "ctrl+/" || keyStr == "ctrl+_" { - util.Slog.Debug("FilePicker: Ctrl+/ detected, focusing filter input") 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) - util.Slog.Debug("FilePicker: Initialized search results on focus", "filterText", filterText, "resultsCount", len(m.searchResults)) // Reset selection index if len(m.searchResults) > 0 { m.filteredSelectionIndex = 0 @@ -226,17 +233,9 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { if m.filterInputFocused && len(m.searchResults) > 0 && m.filteredSelectionIndex >= 0 && m.filteredSelectionIndex < len(m.searchResults) { selectedResult := m.searchResults[m.filteredSelectionIndex] m.SelectedFile = selectedResult.Path - util.Slog.Debug("FilePicker: Selected file from filtered view", "path", selectedResult.Path) return m, nil } case "up", "down": - // Log arrow key events for debugging navigation issues - util.Slog.Debug("FilePicker: Arrow key case HIT", - "keyStr", keyStr, - "filterInputFocused", m.filterInputFocused, - "filterValue", m.filterInput.Value(), - "searchResultsCount", len(m.searchResults), - "willHandle", m.filterInputFocused && len(m.searchResults) > 0) // Handle arrow keys for filtered view when filter is active and there are search results if m.filterInputFocused && len(m.searchResults) > 0 { @@ -251,7 +250,6 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { m.filteredSelectionIndex = 0 } } - util.Slog.Debug("FilePicker: Updated filtered selection", "index", m.filteredSelectionIndex, "total", len(m.searchResults)) // Update preview for the selected file if m.filteredSelectionIndex >= 0 && m.filteredSelectionIndex < len(m.searchResults) { @@ -259,15 +257,12 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { if selectedResult.Path != m.previewFile { m.previewFile = selectedResult.Path m.previewContent = m.getFilePreviewContent(selectedResult.Path) - util.Slog.Debug("FilePicker: Updated preview from filtered selection", "path", selectedResult.Path) } } // Don't pass arrow keys to filter input when navigating filtered results - util.Slog.Debug("FilePicker: Returning early from arrow key handler") return m, nil } - util.Slog.Debug("FilePicker: Arrow key not handled, falling through") } case clearErrorMsg: @@ -279,19 +274,50 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { // Update filter input if focused if m.filterInputFocused { - util.Slog.Debug("FilePicker: Updating filter input", "msgType", fmt.Sprintf("%T", msg)) oldFilterValue := m.filterInput.Value() m.filterInput, filterCmd = m.filterInput.Update(msg) newFilterValue := m.filterInput.Value() - util.Slog.Debug("FilePicker: Filter input updated", "oldValue", oldFilterValue, "newValue", newFilterValue, "cursorPos", m.filterInput.Cursor) - // If filter value changed, update search results + // 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() + } + + // Ensure channel exists + if m.debounceChannel == nil { + m.debounceChannel = make(chan string, 1) + } + + // 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) - util.Slog.Debug("FilePicker: Updated search results from filter change", "filterText", filterText, "resultsCount", len(m.searchResults)) - // Reset selection index when filter changes if len(m.searchResults) > 0 { m.filteredSelectionIndex = 0 } else { @@ -301,13 +327,9 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { m.searchResults = []SearchResult{} m.filteredSelectionIndex = -1 } - } - - // 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 { - util.Slog.Debug("FilePicker: Returning early from filter input update (KeyMsg)") - return m, filterCmd + m.lastSearchTime = time.Now() + default: + // No message waiting, continue } } @@ -333,10 +355,10 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect { // In context mode, filter out media files - if m.IsContextMode && isMediaFile(path) { + 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(2*time.Second)) + return m, tea.Batch(cmd, filterCmd, clearErrorAfter(errorDisplayDuration)) } m.SelectedFile = path // Update preview file for context mode @@ -349,7 +371,7 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { if didSelect, path := m.filepicker.DidSelectDisabledFile(msg); didSelect { m.err = errors.New(path + " is not valid.") m.SelectedFile = "" - return m, tea.Batch(cmd, filterCmd, clearErrorAfter(2*time.Second)) + return m, tea.Batch(cmd, filterCmd, clearErrorAfter(errorDisplayDuration)) } return m, tea.Batch(cmd, filterCmd) @@ -444,7 +466,7 @@ func (m FilePicker) recursiveSearch(filterText string, maxDepth int) []SearchRes } // Skip media files in context mode - if m.IsContextMode && isMediaFile(filePath) { + if m.IsContextMode && util.IsMediaFile(filePath) { return nil } @@ -470,19 +492,16 @@ func (m FilePicker) recursiveSearch(filterText string, maxDepth int) []SearchRes } // filterFilePickerView returns a filtered view of the file picker -// Uses recursive search when filter is active in context mode +// 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 - util.Slog.Debug("FilePicker: filterFilePickerView called", "filterText", filterText, "isContextMode", m.IsContextMode, "currentDir", currentDir) - - // In context mode, use recursive search + // In context mode, use cached search results (populated by debounced Update) + // Don't perform search here - it defeats the debounce mechanism if m.IsContextMode { - // Perform recursive search - m.searchResults = m.recursiveSearch(filterText, m.searchDepth) - - util.Slog.Debug("FilePicker: Recursive search completed", "filterText", filterText, "resultsCount", len(m.searchResults)) + // 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 @@ -629,15 +648,6 @@ func (m *FilePicker) SetSize(w, h int) { } } -func isMediaFile(path string) bool { - for _, ext := range util.MediaExtensions { - if strings.HasSuffix(strings.ToLower(path), ext) { - return true - } - } - return false -} - // 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 { @@ -663,8 +673,8 @@ func (m FilePicker) getFilePreviewContent(path string) string { // Convert to string and limit to reasonable size contentStr := string(content) - if len(contentStr) > 10000 { - contentStr = contentStr[:10000] + "\n... (truncated)" + if len(contentStr) > maxPreviewContentSize { + contentStr = contentStr[:maxPreviewContentSize] + "\n... (truncated)" } return contentStr @@ -810,20 +820,14 @@ func (m *FilePicker) updatePreviewFromView(view string) { return } - util.Slog.Debug("FilePicker: updatePreviewFromView called", "view_length", len(view)) - util.Slog.Debug("FilePicker: CurrentDirectory", "path", m.filepicker.CurrentDirectory) - util.Slog.Debug("FilePicker: searchResults count", "count", len(m.searchResults)) - // Parse the view to find the currently selected file (marked with cursor) lines := strings.Split(view, "\n") - for i, line := range lines { + for _, line := range lines { // Look for cursor indicator (filepicker uses > for cursor) if strings.Contains(line, ">") { - util.Slog.Debug("FilePicker: Found cursor line", "line_index", i, "line_content", line) // Strip ANSI escape codes cleanLine := stripANSI(line) - util.Slog.Debug("FilePicker: Cleaned line (no ANSI)", "line_content", cleanLine) // Extract file path from the line // Format: "> 4.1kB clients" or "> 18kB chat-pane.go" @@ -831,7 +835,6 @@ func (m *FilePicker) updatePreviewFromView(view string) { parts := strings.Split(cleanLine, ">") if len(parts) > 1 { rest := strings.TrimSpace(parts[1]) - util.Slog.Debug("FilePicker: Extracted rest after >", "rest", rest) // Split by spaces to get size and filename // Format: "4.1kB clients" -> ["4.1kB", "clients"] @@ -839,31 +842,25 @@ func (m *FilePicker) updatePreviewFromView(view string) { if len(fields) >= 2 { // The filename is the last field fileName := fields[len(fields)-1] - util.Slog.Debug("FilePicker: Extracted filename", "filename", fileName) // Get full path by combining with current directory if !strings.HasPrefix(fileName, "/") && !strings.HasPrefix(fileName, "~") { // Relative path fullPath := filepath.Join(m.filepicker.CurrentDirectory, fileName) - util.Slog.Debug("FilePicker: Constructed full path", "full_path", fullPath) // Check if file exists if _, err := os.Stat(fullPath); err != nil { util.Slog.Error("FilePicker: File does not exist", "path", fullPath, "error", err) } else { - util.Slog.Debug("FilePicker: File exists", "path", fullPath) } // Only update if different from current preview if fullPath != m.previewFile { - util.Slog.Debug("FilePicker: Updating preview from navigation", "path", fullPath) m.previewFile = fullPath m.previewContent = m.getFilePreviewContent(fullPath) } else { - util.Slog.Debug("FilePicker: Preview file unchanged", "path", fullPath) } } else { - util.Slog.Debug("FilePicker: Path is absolute or home, using as-is", "path", fileName) if fileName != m.previewFile { m.previewFile = fileName m.previewContent = m.getFilePreviewContent(fileName) diff --git a/util/const.go b/util/const.go new file mode 100644 index 0000000..48f654d --- /dev/null +++ b/util/const.go @@ -0,0 +1,9 @@ +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 +) From 246d1250d9891fb090d3c024a68a2caf18c0ca41 Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Sun, 15 Feb 2026 20:58:53 +0700 Subject: [PATCH 20/25] refactor: refactor to a single source of recursive dir walker --- components/file-picker.go | 48 ++++------------ util/file-reader.go | 112 ++++++++++++++++++++++++-------------- 2 files changed, 82 insertions(+), 78 deletions(-) diff --git a/components/file-picker.go b/components/file-picker.go index ae65b81..fdb3413 100644 --- a/components/file-picker.go +++ b/components/file-picker.go @@ -432,49 +432,19 @@ func (m FilePicker) recursiveSearch(filterText string, maxDepth int) []SearchRes var results []SearchResult currentDir := m.filepicker.CurrentDirectory - _ = filepath.WalkDir(currentDir, 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 == currentDir { - return nil - } - - // Calculate depth - relPath, relErr := filepath.Rel(currentDir, 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 - } - + // Use the new WalkDirectory utility + _, err := util.WalkDirectory(currentDir, maxDepth, func(filePath string, d fs.DirEntry, relPath string, depth int) bool { // Skip media files in context mode if m.IsContextMode && util.IsMediaFile(filePath) { - return nil + 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 nil + return false } results = append(results, SearchResult{ @@ -483,11 +453,17 @@ func (m FilePicker) recursiveSearch(filterText string, maxDepth int) []SearchRes IsDir: d.IsDir(), Size: info.Size(), }) + return true } - return nil + return false }) + if err != nil { + // Log error but return what we have + fmt.Printf("Error during recursive search: %v\n", err) + } + return results } diff --git a/util/file-reader.go b/util/file-reader.go index b7998da..f93ac6f 100644 --- a/util/file-reader.go +++ b/util/file-reader.go @@ -12,6 +12,70 @@ import ( 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) { @@ -169,61 +233,25 @@ func FormatFolderContents(contents map[string]string, filePaths []string) string // CountFilesInFolder counts the number of non-media files in a folder func CountFilesInFolder(path string, maxDepth int) (int, error) { - count := 0 - - 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 - } - + // 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 nil + return false } - // Skip media files ext := strings.ToLower(filepath.Ext(filePath)) if slices.Contains(MediaExtensions, ext) { - return nil + return false } - - count++ - return nil + return true }) if err != nil { return 0, fmt.Errorf("failed to count files in folder: %w", err) } - return count, nil + return len(results), nil } // ListFolderEntries returns a list of files and folders in a directory (non-recursive) From 8fd9b1cde27ab9ccfa9094e30d7dc3139a0cb73a Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Sun, 15 Feb 2026 21:20:55 +0700 Subject: [PATCH 21/25] refactor: added caching between panes (hopefully is working) --- components/file-picker.go | 81 +++++++++++++++++++++++++++------------ 1 file changed, 56 insertions(+), 25 deletions(-) diff --git a/components/file-picker.go b/components/file-picker.go index fdb3413..8a20a91 100644 --- a/components/file-picker.go +++ b/components/file-picker.go @@ -65,13 +65,16 @@ type FilePicker struct { previewFile string // Preview content previewContent string - // Cached preview lines to avoid re-splitting on every render - previewLines []string - // Cached directory entries to avoid excessive I/O - cachedDirEntries []os.DirEntry - cachedDirPath string - // Last rendered view for tracking selection changes - lastRenderedView string + + // Caching + cachedPreviewRendered string + cachedPreviewFile string + cachedPreviewMtime time.Time + cachedTerminalWidth int + cachedIsText bool + cachedDirEntries []os.DirEntry + cachedDirPath string + // Terminal width for line truncation in preview terminalWidth int // Selection index for filtered view (when filter is active) @@ -336,12 +339,21 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { // Update filepicker m.filepicker, cmd = m.filepicker.Update(msg) - // Track selection changes for preview update - currentView := m.filepicker.View() - if currentView != m.lastRenderedView { - m.lastRenderedView = currentView - // Try to extract currently selected file from view - m.updatePreviewFromView(currentView) + // 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 @@ -659,6 +671,7 @@ func (m FilePicker) getFilePreviewContent(path string) string { // 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 +// FIX 7: Uses caching to avoid re-rendering on every call func (m FilePicker) GetPreviewView(height int) string { if !m.IsContextMode || m.previewFile == "" { return "" @@ -682,8 +695,25 @@ func (m FilePicker) GetPreviewView(height int) string { return lipgloss.JoinVertical(lipgloss.Left, header, path) } - // Check if it's a text file - don't show preview for binary files - if !isTextFile(m.previewFile) { + // FIX 7: 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 + } + + // FIX 7: 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 = isTextFile(m.previewFile) + m.cachedIsText = isText + } + + if !isText { header := lipgloss.NewStyle(). Foreground(m.colors.HighlightColor). Bold(true). @@ -694,7 +724,7 @@ func (m FilePicker) GetPreviewView(height int) string { return lipgloss.JoinVertical(lipgloss.Left, header, "", message) } - // Split content into lines + // Generate preview (same logic as before) lines := strings.Split(m.previewContent, "\n") // Limit to available height (reserve space for header) @@ -706,15 +736,11 @@ func (m FilePicker) GetPreviewView(height int) string { lineNumWidth := len(fmt.Sprintf("%d", len(lines))) // Calculate max line width (50% of terminal width) - // We need to account for line numbers and borders - // Line format: "NNN │ content" where NNN is line number - // Reserve space for line numbers, separator, and padding - maxLineWidth := (m.terminalWidth / 2) - lineNumWidth - 5 // 5 for " │ " and padding + maxLineWidth := (m.terminalWidth / 2) - lineNumWidth - 5 // truncate detection hasLongLines := false for _, line := range lines { - // Count visible characters (excluding ANSI codes) visibleLen := utf8.RuneCountInString(stripANSI(line)) if visibleLen > maxLineWidth { hasLongLines = true @@ -722,13 +748,10 @@ func (m FilePicker) GetPreviewView(height int) string { } } - // If any line is too long, truncate all lines to max width if hasLongLines { for i := range lines { visibleLen := utf8.RuneCountInString(stripANSI(lines[i])) if visibleLen > maxLineWidth { - // Truncate line and add ellipsis - // We need to preserve ANSI codes while truncating truncated := truncateLineWithANSI(lines[i], maxLineWidth) lines[i] = truncated } @@ -756,7 +779,15 @@ func (m FilePicker) GetPreviewView(height int) string { previewLines = append(previewLines, lineNumStyle.Render(lineNum)+contentStyle.Render(line)) } - return strings.Join(previewLines, "\n") + // FIX 7: 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 } // truncateLineWithANSI truncates a line to max visible characters while preserving ANSI codes From 7954b9f5e40dfc79e3cb03e46e068d6f7d0dfb95 Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Sun, 15 Feb 2026 21:33:40 +0700 Subject: [PATCH 22/25] refactor: remove redundant nil check --- components/file-picker.go | 73 ++++++++++++++++++++++++++------------- util/const.go | 9 +++++ 2 files changed, 58 insertions(+), 24 deletions(-) diff --git a/components/file-picker.go b/components/file-picker.go index 8a20a91..65625dc 100644 --- a/components/file-picker.go +++ b/components/file-picker.go @@ -24,13 +24,7 @@ type debounceSearchMsg struct { filterText string } -// File picker constants -const ( - maxPreviewFileSize = 1024 * 1024 // 1MB - max file size for text preview - 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 -) +var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) // SearchResult represents a file found during recursive search type SearchResult struct { @@ -74,6 +68,9 @@ type FilePicker struct { 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 @@ -142,14 +139,43 @@ func NewFilePicker( searchDepth: searchDepth, colors: colors, debounceChannel: make(chan string, 1), + textFileCache: make(map[string]bool), + textFileCacheMtime: make(map[string]time.Time), } return filePicker } -func isTextFile(path string) bool { +// 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 } @@ -159,7 +185,7 @@ func isTextFile(path string) bool { return false } // Skip files larger than 1MB as they're unlikely to be suitable for quick preview - if fileInfo.Size() > maxPreviewFileSize { + if fileInfo.Size() > util.MaxPreviewFileSize { return false } @@ -172,7 +198,7 @@ func isTextFile(path string) bool { defer file.Close() // Read only first 1024 bytes for UTF-8 validity check - buf := make([]byte, utf8CheckBufferSize) + buf := make([]byte, util.Utf8CheckBufferSize) n, err := file.Read(buf) if err != nil && err != io.EOF { return false @@ -290,11 +316,6 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { m.debounceTimer.Stop() } - // Ensure channel exists - if m.debounceChannel == nil { - m.debounceChannel = make(chan string, 1) - } - // Schedule debounced search using AfterFunc // When timer fires, it sends the filter text to the channel m.debounceTimer = time.AfterFunc(util.FilePickerDebounce, func() { @@ -370,7 +391,7 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { 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(errorDisplayDuration)) + return m, tea.Batch(cmd, filterCmd, clearErrorAfter(util.ErrorDisplayDuration)) } m.SelectedFile = path // Update preview file for context mode @@ -383,7 +404,7 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { if didSelect, path := m.filepicker.DidSelectDisabledFile(msg); didSelect { m.err = errors.New(path + " is not valid.") m.SelectedFile = "" - return m, tea.Batch(cmd, filterCmd, clearErrorAfter(errorDisplayDuration)) + return m, tea.Batch(cmd, filterCmd, clearErrorAfter(util.ErrorDisplayDuration)) } return m, tea.Batch(cmd, filterCmd) @@ -440,12 +461,18 @@ func (m FilePicker) GetFilterInputView() string { // 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) []SearchResult { var results []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 @@ -473,7 +500,7 @@ func (m FilePicker) recursiveSearch(filterText string, maxDepth int) []SearchRes if err != nil { // Log error but return what we have - fmt.Printf("Error during recursive search: %v\n", err) + util.Slog.Warn("FilePicker: Error during recursive search", "error", err) } return results @@ -649,7 +676,7 @@ func (m FilePicker) getFilePreviewContent(path string) string { } // Check if it's a text file - if !isTextFile(path) { + if !m.isTextFile(path) { return "[Binary file - preview not available]" } @@ -661,8 +688,8 @@ func (m FilePicker) getFilePreviewContent(path string) string { // Convert to string and limit to reasonable size contentStr := string(content) - if len(contentStr) > maxPreviewContentSize { - contentStr = contentStr[:maxPreviewContentSize] + "\n... (truncated)" + if len(contentStr) > util.MaxPreviewContentSize { + contentStr = contentStr[:util.MaxPreviewContentSize] + "\n... (truncated)" } return contentStr @@ -709,7 +736,7 @@ func (m FilePicker) GetPreviewView(height int) string { isText := m.cachedIsText if m.cachedPreviewFile != m.previewFile { // File changed, re-check text validity - isText = isTextFile(m.previewFile) + isText = m.isTextFile(m.previewFile) m.cachedIsText = isText } @@ -815,8 +842,6 @@ func truncateLineWithANSI(line string, maxLen int) string { // stripANSI removes ANSI escape codes from a string func stripANSI(s string) string { - // ANSI escape code pattern: \x1b[...m - ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`) return ansiRegex.ReplaceAllString(s, "") } diff --git a/util/const.go b/util/const.go index 48f654d..c3820af 100644 --- a/util/const.go +++ b/util/const.go @@ -7,3 +7,12 @@ 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 +) From 72d55ad0e00e6920e258aa52d93a5536f68e33e5 Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Sun, 15 Feb 2026 22:02:25 +0700 Subject: [PATCH 23/25] refactor: (BIG) refactor file-picker, separate the search & preview logic --- components/file-picker-preview.go | 261 ++++++++++++++++ components/file-picker-search.go | 237 +++++++++++++++ components/file-picker.go | 485 +----------------------------- util/formatter.go | 41 +++ util/shared-types.go | 8 + 5 files changed, 557 insertions(+), 475 deletions(-) create mode 100644 components/file-picker-preview.go create mode 100644 components/file-picker-search.go 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 65625dc..8e1ef42 100644 --- a/components/file-picker.go +++ b/components/file-picker.go @@ -2,12 +2,9 @@ package components import ( "errors" - "fmt" "io" - "io/fs" "os" "path/filepath" - "regexp" "strings" "time" "unicode/utf8" @@ -24,16 +21,6 @@ type debounceSearchMsg struct { filterText string } -var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) - -// SearchResult represents a file found during recursive search -type SearchResult struct { - Path string - RelPath string // Relative to current directory - IsDir bool - Size int64 -} - type FilePicker struct { SelectedFile string PrevView util.ViewMode @@ -50,7 +37,7 @@ type FilePicker struct { // Filtered files list filteredFiles []os.DirEntry // Search results for recursive fuzzy matching - searchResults []SearchResult + searchResults []util.SearchResult // Search depth from config searchDepth int // Theme colors for styling @@ -135,7 +122,7 @@ func NewFilePicker( filterInput: filterInput, filterInputFocused: false, filteredFiles: []os.DirEntry{}, - searchResults: []SearchResult{}, + searchResults: []util.SearchResult{}, searchDepth: searchDepth, colors: colors, debounceChannel: make(chan string, 1), @@ -230,7 +217,7 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { // Initialize search results if filter has content filterText := strings.ToLower(m.filterInput.Value()) if filterText != "" && m.IsContextMode { - m.searchResults = m.recursiveSearch(filterText, m.searchDepth) + m.searchResults = m.RecursiveSearch(filterText, m.searchDepth) // Reset selection index if len(m.searchResults) > 0 { m.filteredSelectionIndex = 0 @@ -285,7 +272,7 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { selectedResult := m.searchResults[m.filteredSelectionIndex] if selectedResult.Path != m.previewFile { m.previewFile = selectedResult.Path - m.previewContent = m.getFilePreviewContent(selectedResult.Path) + m.previewContent = m.GetFilePreviewContent(selectedResult.Path) } } @@ -341,14 +328,14 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { case filterText := <-m.debounceChannel: // Timer fired - perform the debounced search if filterText != "" && m.IsContextMode { - m.searchResults = m.recursiveSearch(filterText, m.searchDepth) + m.searchResults = m.RecursiveSearch(filterText, m.searchDepth) if len(m.searchResults) > 0 { m.filteredSelectionIndex = 0 } else { m.filteredSelectionIndex = -1 } } else { - m.searchResults = []SearchResult{} + m.searchResults = []util.SearchResult{} m.filteredSelectionIndex = -1 } m.lastSearchTime = time.Now() @@ -373,7 +360,7 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { if currentView != m.cachedPreviewRendered { // View changed - this could be navigation or just window resize // Parse to find selected file - m.updatePreviewFromView(currentView) + m.UpdatePreviewFromView(currentView) } } @@ -397,7 +384,7 @@ func (m FilePicker) Update(msg tea.Msg) (FilePicker, tea.Cmd) { // Update preview file for context mode if m.IsContextMode { m.previewFile = path - m.previewContent = m.getFilePreviewContent(path) + m.previewContent = m.GetFilePreviewContent(path) } } @@ -421,7 +408,7 @@ func (m FilePicker) View() string { // If filter input has content, filter the file picker view filterText := strings.ToLower(m.filterInput.Value()) if filterText != "" { - filePickerView = m.filterFilePickerView(filterText) + filePickerView = m.FilterFilePickerView(filterText) } // Show filter input beneath the file listing @@ -448,7 +435,7 @@ func (m FilePicker) GetFilePickerViewWithoutFilter() string { // If filter input has content, filter the file picker view filterText := strings.ToLower(m.filterInput.Value()) if filterText != "" { - filePickerView = m.filterFilePickerView(filterText) + filePickerView = m.FilterFilePickerView(filterText) } return filePickerView @@ -458,455 +445,3 @@ func (m FilePicker) GetFilePickerViewWithoutFilter() string { func (m FilePicker) GetFilterInputView() string { return m.filterInput.View() } - -// 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) []SearchResult { - var results []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, 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 := 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 := formatSize(size) - - lines = append(lines, name+" "+sizeStr) - } - - return strings.Join(lines, "\n") -} - -// 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]) -} - -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 -// FIX 7: 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) - } - - // FIX 7: 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 - } - - // FIX 7: 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 (same logic as before) - 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(stripANSI(line)) - if visibleLen > maxLineWidth { - hasLongLines = true - break - } - } - - if hasLongLines { - for i := range lines { - visibleLen := utf8.RuneCountInString(stripANSI(lines[i])) - if visibleLen > maxLineWidth { - truncated := 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 := 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)) - } - - // FIX 7: 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 -} - -// 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 := stripANSI(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 -} - -// stripANSI removes ANSI escape codes from a string -func stripANSI(s string) string { - return ansiRegex.ReplaceAllString(s, "") -} - -// 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 := stripANSI(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) - } else { - } - - // Only update if different from current preview - if fullPath != m.previewFile { - m.previewFile = fullPath - m.previewContent = m.getFilePreviewContent(fullPath) - } else { - } - } 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 -} diff --git a/util/formatter.go b/util/formatter.go index 8fbbacd..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" @@ -441,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-types.go b/util/shared-types.go index 37b9d46..d172c15 100644 --- a/util/shared-types.go +++ b/util/shared-types.go @@ -181,3 +181,11 @@ func IsTextOrCodeExtension(ext string) bool { } 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 +} From 021ea351197aa03219b0316deabc637e6f7e3fbd Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Tue, 24 Feb 2026 10:01:04 +0700 Subject: [PATCH 24/25] refactor: remove dynamic height for preview pane --- util/dimensions.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 } From edeb338981f9b92b99a73fda2a59b563d9d31f66 Mon Sep 17 00:00:00 2001 From: gugugiyu Date: Tue, 24 Feb 2026 10:18:17 +0700 Subject: [PATCH 25/25] refactor: make filter field implicit --- components/file-picker.go | 31 ++++++++++++++++++++++--------- panes/prompt-pane.go | 5 +++++ views/main-view.go | 17 +++++++++++------ 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/components/file-picker.go b/components/file-picker.go index 8e1ef42..870a902 100644 --- a/components/file-picker.go +++ b/components/file-picker.go @@ -411,15 +411,18 @@ func (m FilePicker) View() string { filePickerView = m.FilterFilePickerView(filterText) } - // Show filter input beneath the file listing - filterInputView := m.filterInput.View() - - // Join file picker view and filter input vertically - return lipgloss.JoinVertical( - lipgloss.Left, - filePickerView, - filterInputView, - ) + // 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 } // GetFilePickerViewWithoutFilter returns the file picker view without the filter input @@ -441,7 +444,17 @@ func (m FilePicker) GetFilePickerViewWithoutFilter() string { 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/panes/prompt-pane.go b/panes/prompt-pane.go index c5e7924..a99885e 100644 --- a/panes/prompt-pane.go +++ b/panes/prompt-pane.go @@ -841,6 +841,11 @@ 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 diff --git a/views/main-view.go b/views/main-view.go index 5e67b96..354c0c9 100644 --- a/views/main-view.go +++ b/views/main-view.go @@ -635,13 +635,18 @@ func (m MainView) View() string { if previewView != "" { // Show file picker and preview side by side - // Filter input is shown above the preview + // Filter input is shown above the preview only when visible filterInputView := m.promptPane.GetFilePickerFilterInputView() - previewWithFilter := lipgloss.JoinVertical( - lipgloss.Left, - filterInputView, - previewView, - ) + 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().