Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
34b49a6
feat: added context insertion pane (#20)
gugugiyu Feb 10, 2026
45d43cb
fix: make context files discovery recursive and fix phantom panes
gugugiyu Feb 10, 2026
f828162
feat: added line-preview
gugugiyu Feb 11, 2026
769daf0
chore: clean up coderabbit's autosuggestion
gugugiyu Feb 11, 2026
c46c3d9
chore: more fixes for maxDepth race condition, binary detection and f…
gugugiyu Feb 11, 2026
cd5dc7d
chore: more coderabbit fixes
gugugiyu Feb 11, 2026
18c4ea0
chore: even more coderabbit fixes
gugugiyu Feb 11, 2026
da6c791
fix: reuse file picker components, lots of bug fixes
gugugiyu Feb 14, 2026
d60b592
chore: use decodeRuneInString to account for multi-byte char and caching
gugugiyu Feb 14, 2026
2ea354f
fix: allow literal 'q' to be typed in filter input mode
gugugiyu Feb 14, 2026
c359348
chore: added preview right bar and recursive fuzzing
gugugiyu Feb 14, 2026
e47260b
chore: fixed folder preview with keybinding showing recursive content
gugugiyu Feb 14, 2026
bd629f5
fix: preview tab can't be moved with arrows
gugugiyu Feb 15, 2026
0b103d7
ui: better filter layout and colorized
gugugiyu Feb 15, 2026
17fc252
refactor: support even more text extensions, remove redudant show ico…
gugugiyu Feb 15, 2026
c17ea02
chore: switch to ctrl+g due to ctrl+l being conflicted as terminal clear
gugugiyu Feb 15, 2026
677362b
chore: removed duplicated extensions from the code and text lists
gugugiyu Feb 15, 2026
c669fa2
refactor: switch to os.Open and read up to N bytes only
gugugiyu Feb 15, 2026
6e53b60
refactor: added debounced when filter, move const to consts.go
gugugiyu Feb 15, 2026
246d125
refactor: refactor to a single source of recursive dir walker
gugugiyu Feb 15, 2026
8fd9b1c
refactor: added caching between panes (hopefully is working)
gugugiyu Feb 15, 2026
7954b9f
refactor: remove redundant nil check
gugugiyu Feb 15, 2026
72d55ad
refactor: (BIG) refactor file-picker, separate the search & preview l…
gugugiyu Feb 15, 2026
021ea35
refactor: remove dynamic height for preview pane
gugugiyu Feb 24, 2026
edeb338
refactor: make filter field implicit
gugugiyu Feb 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,17 +141,19 @@ We provide a `config.json` file within your directory for easy access to essenti
"provider": "openai", // openai, gemini, openrouter
"maxAttachmentSizeMb": 3,
"includeReasoningTokensInContext": true,
"sessionExportDir": "/must/be/absolute/path/to/exports"
"sessionExportDir": "/must/be/absolute/path/to/exports",
"contextMaxDepth": 2
}
```

- `providerBaseUrl`: The url can be anything that follows OpenAI API standard ( [ollama](http://localhost:11434), [lmstudio](http://127.0.0.1:1234), etc)
- `chatGPTApiUrl` [obsolete]: same as `providerBaseUrl`
- `systemMessage` field is available for customizing system prompt messages. **Better to set it from the app**
- `defaultModel` field sets the default model. **Better to set it from the app**
- `maxAttachmentSizeMb` field sets maximum allowed image size
- `includeReasoningTokensInContext` field sets whether to include reasoning tokens in the next request or not.
- `sessionExportDir` allows to specify directory for session exports. If not set, exports are saved to current directory. **The path must be an absolute path**
- `providerBaseUrl`: The url can be anything that follows OpenAI API standard ( [ollama](http://localhost:11434), [lmstudio](http://127.0.0.1:1234), etc)
- `chatGPTApiUrl` [obsolete]: same as `providerBaseUrl`
- `systemMessage` field is available for customizing system prompt messages. **Better to set it from the app**
- `defaultModel` field sets the default model. **Better to set it from the app**
- `maxAttachmentSizeMb` field sets maximum allowed image size
- `includeReasoningTokensInContext` field sets whether to include reasoning tokens in the next request or not.
- `sessionExportDir` allows to specify directory for session exports. If not set, exports are saved to current directory. **The path must be an absolute path**
- `contextMaxDepth`: Sets the maximum depth for recursive file search when using `@` to add file context. Default is `2`. Higher values allow searching deeper into subdirectories but may be slower.


### Providers
Expand Down Expand Up @@ -253,6 +255,12 @@ nekot -n
- `esc`: Exit insert mode for the prompt
* When in 'Prompt editor' mode, pressing `esc` second time will close editor
- `Ctrl+a`: open file picker for attaching images. You can also attach images by typing: [img=/path/to/image]
- `@`: Open context picker for adding file/folder context (type `@` at the beginning of input or after a space)
* Context picker shows a preview pane on the right for text files
* Use `Ctrl+/` to focus the filter input for searching files
* Filter performs recursive search through subdirectories (depth controlled by `contextMaxDepth` config)
* Preview shows line numbers and uses theme colors for better readability
* Binary files and directories show appropriate messages instead of preview

## Chat Messages Pane

Expand Down
9 changes: 9 additions & 0 deletions clients/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,15 @@ func buildChatHistory(msgs []util.LocalStoreMessage, includeReasoning bool) ([]*
messageContent += singleMessage.Content
}

if singleMessage.ContextContent != "" {
util.Slog.Debug("Gemini: Including ContextContent in message",
"contextContentLength", len(singleMessage.ContextContent))
if messageContent != "" {
messageContent += "\n\n"
}
messageContent += singleMessage.ContextContent
}

message := genai.Content{
Parts: []genai.Part{},
Role: role,
Expand Down
9 changes: 9 additions & 0 deletions clients/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,15 @@ func (c OpenAiClient) constructCompletionRequestPayload(
messageContent += singleMessage.Content
}

if singleMessage.ContextContent != "" {
util.Slog.Debug("OpenAI: Including ContextContent in message",
"contextContentLength", len(singleMessage.ContextContent))
if messageContent != "" {
messageContent += "\n\n"
}
messageContent += singleMessage.ContextContent
}

if messageContent != "" {
singleMessage.Content = messageContent
}
Expand Down
9 changes: 9 additions & 0 deletions clients/openrouter.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,15 @@ func setRequestContext(
messageContent += singleMessage.Content
}

if singleMessage.ContextContent != "" {
util.Slog.Debug("OpenRouter: Including ContextContent in message",
"contextContentLength", len(singleMessage.ContextContent))
if messageContent != "" {
messageContent += "\n\n"
}
messageContent += singleMessage.ContextContent
}

if len(singleMessage.ToolCalls) > 0 {
toolCalls := constructOpenrouterToolCalls(singleMessage)
chat = append(chat, toolCalls...)
Expand Down
261 changes: 261 additions & 0 deletions components/file-picker-preview.go
Original file line number Diff line number Diff line change
@@ -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{}
}
Loading