diff --git a/cmd/root.go b/cmd/root.go
index 411106e4..a1c3f4f6 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -1065,11 +1065,13 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
termHeight = 24
}
+ cwd, _ := os.Getwd()
appModel := ui.NewAppModel(appInstance, ui.AppModelOptions{
CompactMode: viper.GetBool("compact"),
ModelName: modelName,
ProviderName: providerName,
LoadingMessage: loadingMessage,
+ Cwd: cwd,
Width: termWidth,
Height: termHeight,
ServerNames: serverNames,
diff --git a/internal/ui/file_processor.go b/internal/ui/file_processor.go
new file mode 100644
index 00000000..5181142f
--- /dev/null
+++ b/internal/ui/file_processor.go
@@ -0,0 +1,129 @@
+package ui
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+// fileTokenPattern matches @file references in user text. Supports:
+// - @"path with spaces.txt" (quoted)
+// - @path/to/file.txt (unquoted, no spaces)
+var fileTokenPattern = regexp.MustCompile(`@"[^"]+"|@[^\s]+`)
+
+// ProcessFileAttachments scans the user's input text for @file references,
+// reads each referenced file, and returns the text with @tokens replaced by
+// XML-wrapped file content. Non-file @ tokens (like email addresses) are left
+// unchanged.
+//
+// Returns the original text unchanged if no valid @file references are found.
+func ProcessFileAttachments(text string, cwd string) string {
+ tokens := fileTokenPattern.FindAllString(text, -1)
+ if len(tokens) == 0 {
+ return text
+ }
+
+ result := text
+ for _, token := range tokens {
+ path := tokenToPath(token)
+ if path == "" {
+ continue
+ }
+
+ absPath, err := resolvePath(path, cwd)
+ if err != nil {
+ // Not a valid file reference — leave the token as-is.
+ // This handles cases like email addresses (@user) gracefully.
+ continue
+ }
+
+ info, err := os.Stat(absPath)
+ if err != nil {
+ continue
+ }
+
+ // Skip directories — we only attach file content.
+ if info.IsDir() {
+ continue
+ }
+
+ // Skip empty files.
+ if info.Size() == 0 {
+ continue
+ }
+
+ content, err := os.ReadFile(absPath)
+ if err != nil {
+ continue
+ }
+
+ // Build the XML-wrapped replacement.
+ wrapped := wrapFileContent(absPath, content)
+ result = strings.Replace(result, token, wrapped, 1)
+ }
+
+ return result
+}
+
+// tokenToPath strips the @ prefix and optional quotes from a token,
+// returning the raw file path. Returns "" for invalid tokens.
+func tokenToPath(token string) string {
+ if !strings.HasPrefix(token, "@") {
+ return ""
+ }
+ path := token[1:]
+
+ // Strip quotes.
+ if strings.HasPrefix(path, `"`) && strings.HasSuffix(path, `"`) {
+ path = path[1 : len(path)-1]
+ }
+
+ // Reject obviously non-file tokens (e.g. bare @ or @-flags).
+ if path == "" || strings.HasPrefix(path, "-") {
+ return ""
+ }
+
+ return path
+}
+
+// resolvePath resolves a potentially relative file path to an absolute path.
+// Supports ~/ expansion and relative paths. No CWD restriction — the user
+// can reference any file they have read access to.
+func resolvePath(path string, cwd string) (string, error) {
+ // Expand ~/
+ if strings.HasPrefix(path, "~/") {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("cannot expand ~: %w", err)
+ }
+ path = filepath.Join(home, path[2:])
+ }
+
+ // Resolve relative to cwd.
+ if !filepath.IsAbs(path) {
+ path = filepath.Join(cwd, path)
+ }
+
+ // Clean and resolve symlinks for consistent paths.
+ absPath, err := filepath.Abs(path)
+ if err != nil {
+ return "", fmt.Errorf("invalid path: %w", err)
+ }
+
+ // Resolve symlinks so the displayed path is canonical.
+ resolved, err := filepath.EvalSymlinks(absPath)
+ if err != nil {
+ // EvalSymlinks fails if the file doesn't exist — fall back to
+ // the cleaned absolute path and let the caller's Stat handle it.
+ return absPath, nil
+ }
+
+ return resolved, nil
+}
+
+// wrapFileContent wraps file content in XML tags for LLM consumption.
+func wrapFileContent(absPath string, content []byte) string {
+ return fmt.Sprintf("\n%s\n", absPath, string(content))
+}
diff --git a/internal/ui/file_suggestions.go b/internal/ui/file_suggestions.go
new file mode 100644
index 00000000..69c2d5a4
--- /dev/null
+++ b/internal/ui/file_suggestions.go
@@ -0,0 +1,389 @@
+package ui
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strings"
+ "unicode/utf8"
+)
+
+// FileSuggestion represents a single file or directory suggestion for the @
+// autocomplete popup.
+type FileSuggestion struct {
+ // RelPath is the path relative to the search base (e.g. "cmd/kit/main.go").
+ RelPath string
+ // IsDir is true when the entry is a directory.
+ IsDir bool
+ // Score is the fuzzy match score (higher is better).
+ Score int
+}
+
+// maxFileSuggestions is the maximum number of file suggestions returned.
+const maxFileSuggestions = 20
+
+// ExtractAtPrefix checks the current line for an @-file trigger at cursorCol.
+// It returns:
+// - hasAt: true if a valid @ trigger was found
+// - prefix: the text after @ (possibly empty) that the user has typed so far
+// - startIdx: byte offset of the @ character in the line
+//
+// The @ must appear at the start of the line or after whitespace. Quoted paths
+// are supported: @"path with spaces" — the returned prefix strips quotes.
+func ExtractAtPrefix(line string, cursorCol int) (hasAt bool, prefix string, startIdx int) {
+ if cursorCol > len(line) {
+ cursorCol = len(line)
+ }
+
+ // Walk backwards from cursorCol to find the @ character.
+ text := line[:cursorCol]
+
+ // Find the last @ that is preceded by whitespace or is at position 0.
+ atIdx := -1
+ for i := len(text) - 1; i >= 0; i-- {
+ if text[i] == '@' {
+ // Must be at start of line or preceded by whitespace.
+ if i == 0 || text[i-1] == ' ' || text[i-1] == '\t' {
+ atIdx = i
+ break
+ }
+ }
+ // Stop scanning if we hit a space — the @ we want must be in the
+ // current "word".
+ if text[i] == ' ' || text[i] == '\t' {
+ break
+ }
+ }
+
+ if atIdx < 0 {
+ return false, "", 0
+ }
+
+ raw := text[atIdx+1:]
+
+ // Handle quoted paths: @"some path" — strip leading quote.
+ if strings.HasPrefix(raw, `"`) {
+ raw = strings.TrimPrefix(raw, `"`)
+ raw = strings.TrimSuffix(raw, `"`)
+ }
+
+ return true, raw, atIdx
+}
+
+// GetFileSuggestions returns file/directory suggestions matching the given
+// prefix. It tries `git ls-files` first (fast, respects .gitignore), then
+// falls back to a simple directory walk.
+//
+// If prefix contains a path separator the search is scoped to that
+// subdirectory. For example, prefix "cmd/k" searches inside "cmd/" for
+// entries matching "k".
+func GetFileSuggestions(prefix string, cwd string) []FileSuggestion {
+ // Resolve the base directory and filter query from the prefix.
+ baseDir, query := splitPrefixPath(prefix)
+
+ searchDir := cwd
+ if baseDir != "" {
+ candidate := resolveSearchDir(baseDir, cwd)
+ if info, err := os.Stat(candidate); err == nil && info.IsDir() {
+ searchDir = candidate
+ } else {
+ return nil // invalid base directory
+ }
+ }
+
+ files := listFiles(searchDir, cwd)
+ if len(files) == 0 {
+ return nil
+ }
+
+ // Prepend baseDir so results display as "cmd/main.go" not just "main.go".
+ if baseDir != "" {
+ for i := range files {
+ files[i].RelPath = baseDir + files[i].RelPath
+ }
+ }
+
+ return fuzzyFilterFiles(files, prefix, query)
+}
+
+// splitPrefixPath separates a prefix like "cmd/kit/m" into
+// baseDir="cmd/kit/" and query="m". If there is no separator the
+// baseDir is empty and query is the full prefix.
+func splitPrefixPath(prefix string) (baseDir, query string) {
+ // Handle ~ expansion display (we keep it in the prefix for display
+ // but resolve it when actually searching).
+ idx := strings.LastIndex(prefix, "/")
+ if idx < 0 {
+ return "", prefix
+ }
+ return prefix[:idx+1], prefix[idx+1:]
+}
+
+// resolveSearchDir converts a baseDir from the prefix into an absolute path.
+// Supports ~/, ../, and absolute paths.
+func resolveSearchDir(baseDir, cwd string) string {
+ // Expand ~/
+ if strings.HasPrefix(baseDir, "~/") {
+ if home, err := os.UserHomeDir(); err == nil {
+ return filepath.Join(home, baseDir[2:])
+ }
+ }
+
+ // Absolute paths
+ if filepath.IsAbs(baseDir) {
+ return filepath.Clean(baseDir)
+ }
+
+ // Relative to cwd
+ return filepath.Join(cwd, baseDir)
+}
+
+// listFiles returns files and directories within searchDir, relative to that
+// directory. Uses `git ls-files` when inside a git repo for speed and
+// .gitignore awareness, otherwise falls back to os.ReadDir.
+func listFiles(searchDir, cwd string) []FileSuggestion {
+ // Try git ls-files first (fast, respects .gitignore).
+ if files := listFilesGit(searchDir, cwd); files != nil {
+ return files
+ }
+ return listFilesReadDir(searchDir)
+}
+
+// listFilesGit uses `git ls-files` and `git ls-files --others --exclude-standard`
+// to list tracked and untracked-but-not-ignored files.
+func listFilesGit(searchDir, cwd string) []FileSuggestion {
+ // Check if we're in a git repo.
+ check := exec.Command("git", "rev-parse", "--show-toplevel")
+ check.Dir = cwd
+ if err := check.Run(); err != nil {
+ return nil
+ }
+
+ seen := make(map[string]bool)
+ var results []FileSuggestion
+
+ // Tracked files.
+ cmd := exec.Command("git", "ls-files")
+ cmd.Dir = searchDir
+ out, err := cmd.Output()
+ if err == nil {
+ for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+ if line == "" {
+ continue
+ }
+ // Normalize separators.
+ line = filepath.ToSlash(line)
+ addFileEntries(&results, seen, line, searchDir)
+ }
+ }
+
+ // Untracked, non-ignored files.
+ cmd2 := exec.Command("git", "ls-files", "--others", "--exclude-standard")
+ cmd2.Dir = searchDir
+ out2, err := cmd2.Output()
+ if err == nil {
+ for _, line := range strings.Split(strings.TrimSpace(string(out2)), "\n") {
+ if line == "" {
+ continue
+ }
+ line = filepath.ToSlash(line)
+ addFileEntries(&results, seen, line, searchDir)
+ }
+ }
+
+ if len(results) == 0 {
+ return nil
+ }
+ return results
+}
+
+// addFileEntries adds the file and any intermediate directory entries to
+// results if not already seen. Paths are stored with forward slashes.
+func addFileEntries(results *[]FileSuggestion, seen map[string]bool, relPath string, searchDir string) {
+ // Add intermediate directories as suggestions (first component only).
+ parts := strings.SplitN(relPath, "/", 2)
+ if len(parts) > 1 {
+ dir := parts[0] + "/"
+ if !seen[dir] {
+ seen[dir] = true
+ *results = append(*results, FileSuggestion{RelPath: dir, IsDir: true})
+ }
+ }
+
+ // Add the file itself.
+ if !seen[relPath] {
+ seen[relPath] = true
+ *results = append(*results, FileSuggestion{RelPath: relPath, IsDir: false})
+ }
+}
+
+// listFilesReadDir is the fallback when git is not available. Lists immediate
+// children of dir via os.ReadDir, skipping hidden dirs and common noise.
+func listFilesReadDir(dir string) []FileSuggestion {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil
+ }
+
+ skip := map[string]bool{
+ ".git": true, "node_modules": true, ".kit": true,
+ "__pycache__": true, ".venv": true, "vendor": true,
+ }
+
+ var results []FileSuggestion
+ for _, e := range entries {
+ name := e.Name()
+ if skip[name] {
+ continue
+ }
+ // Skip hidden files/dirs (except common config files).
+ if strings.HasPrefix(name, ".") && name != ".env" && name != ".gitignore" {
+ continue
+ }
+ if e.IsDir() {
+ results = append(results, FileSuggestion{RelPath: name + "/", IsDir: true})
+ } else {
+ results = append(results, FileSuggestion{RelPath: name, IsDir: false})
+ }
+ }
+ return results
+}
+
+// fuzzyFilterFiles scores and filters file suggestions against the query,
+// returning the top maxFileSuggestions results sorted by score descending.
+// Directories are boosted slightly so they appear near the top.
+func fuzzyFilterFiles(files []FileSuggestion, fullPrefix, query string) []FileSuggestion {
+ if query == "" && fullPrefix == "" {
+ // No filter — return all (capped).
+ if len(files) > maxFileSuggestions {
+ files = files[:maxFileSuggestions]
+ }
+ return files
+ }
+
+ // When there's a base dir but no query (e.g. "cmd/"), show everything
+ // in that directory.
+ if query == "" {
+ var filtered []FileSuggestion
+ for i := range files {
+ if strings.HasPrefix(files[i].RelPath, fullPrefix) {
+ // Only show direct children of the base directory.
+ rest := files[i].RelPath[len(fullPrefix):]
+ if rest == "" {
+ continue
+ }
+ filtered = append(filtered, files[i])
+ }
+ }
+ if len(filtered) > maxFileSuggestions {
+ filtered = filtered[:maxFileSuggestions]
+ }
+ return filtered
+ }
+
+ var scored []FileSuggestion
+ queryLower := strings.ToLower(query)
+
+ for i := range files {
+ path := files[i].RelPath
+ // When we have a fullPrefix with a dir component, only consider
+ // files under that directory.
+ if fullPrefix != query && !strings.HasPrefix(path, fullPrefix[:len(fullPrefix)-len(query)]) {
+ continue
+ }
+
+ score := scoreFilePath(queryLower, path)
+ if score <= 0 {
+ continue
+ }
+
+ // Boost directories so they appear near the top for navigation.
+ if files[i].IsDir {
+ score += 10
+ }
+
+ files[i].Score = score
+ scored = append(scored, files[i])
+ }
+
+ // Sort by score descending.
+ sort.Slice(scored, func(i, j int) bool {
+ return scored[i].Score > scored[j].Score
+ })
+
+ if len(scored) > maxFileSuggestions {
+ scored = scored[:maxFileSuggestions]
+ }
+ return scored
+}
+
+// scoreFilePath scores a file path against a fuzzy query. Higher is better.
+// Returns 0 if there is no match.
+func scoreFilePath(query, path string) int {
+ pathLower := strings.ToLower(path)
+ baseName := filepath.Base(strings.TrimSuffix(path, "/"))
+ baseNameLower := strings.ToLower(baseName)
+
+ // Exact basename match.
+ if baseNameLower == query {
+ return 1000
+ }
+
+ // Basename starts with query.
+ if strings.HasPrefix(baseNameLower, query) {
+ return 800 - len(baseName) + len(query)
+ }
+
+ // Basename contains query as substring.
+ if strings.Contains(baseNameLower, query) {
+ return 500 - len(baseName) + len(query)
+ }
+
+ // Full path contains query as substring.
+ if strings.Contains(pathLower, query) {
+ return 300 - len(path) + len(query)
+ }
+
+ // Fuzzy character match on basename.
+ if score := fuzzyCharMatch(query, baseNameLower); score > 0 {
+ return score
+ }
+
+ // Fuzzy character match on full path.
+ if score := fuzzyCharMatch(query, pathLower); score > 0 {
+ return score - 50
+ }
+
+ return 0
+}
+
+// fuzzyCharMatch performs character-by-character fuzzy matching. Returns a
+// positive score if all query characters appear in order in the target.
+func fuzzyCharMatch(query, target string) int {
+ if utf8.RuneCountInString(query) > utf8.RuneCountInString(target) {
+ return 0
+ }
+
+ qRunes := []rune(query)
+ tRunes := []rune(target)
+ qi := 0
+ score := 100
+ consecutive := 0
+
+ for ti := 0; ti < len(tRunes) && qi < len(qRunes); ti++ {
+ if tRunes[ti] == qRunes[qi] {
+ qi++
+ consecutive++
+ score += consecutive * 5
+ } else {
+ consecutive = 0
+ score -= 2
+ }
+ }
+
+ if qi < len(qRunes) {
+ return 0
+ }
+ return score
+}
diff --git a/internal/ui/input.go b/internal/ui/input.go
index f108e51f..c2aa3b4a 100644
--- a/internal/ui/input.go
+++ b/internal/ui/input.go
@@ -43,6 +43,18 @@ type InputComponent struct {
argCommand string // command prefix for arg mode (e.g. "/bookmark")
argSynthCmds []SlashCommand // backing storage for synthetic arg entries
+ // File completion state. When the user types @ followed by a partial
+ // file path, the popup shows file/directory suggestions from the cwd.
+ fileMode bool // true when showing @file completions
+ filePrefix string // current text after @ being matched
+ fileAtStartIdx int // byte offset of @ in the textarea value
+ fileSuggestions []FileSuggestion // backing storage for file entries
+ fileSynthCmds []SlashCommand // synthetic SlashCommands wrapping file entries
+
+ // cwd is the working directory used for @file path resolution and
+ // autocomplete suggestions. Set by the parent via SetCwd.
+ cwd string
+
// appCtrl is used for slash commands that mutate app state.
// May be nil in tests; nil-safe.
appCtrl AppController
@@ -90,6 +102,12 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom
}
}
+// SetCwd sets the working directory used for @file autocomplete suggestions
+// and path resolution. Should be called by the parent after construction.
+func (s *InputComponent) SetCwd(cwd string) {
+ s.cwd = cwd
+}
+
// Init implements tea.Model. Starts the cursor blink animation.
func (s *InputComponent) Init() tea.Cmd {
return textarea.Blink
@@ -148,19 +166,29 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
if s.selected < len(s.filtered) {
- if s.argMode {
+ if s.fileMode {
+ s.applyFileCompletion(s.selected)
+ } else if s.argMode {
s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name)
+ s.showPopup = false
+ s.selected = 0
} else {
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
+ s.showPopup = false
+ s.selected = 0
}
- s.showPopup = false
- s.selected = 0
s.textarea.CursorEnd()
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
if s.selected < len(s.filtered) {
+ if s.fileMode {
+ // Apply file completion but don't submit.
+ s.applyFileCompletion(s.selected)
+ s.textarea.CursorEnd()
+ return s, nil
+ }
// Populate textarea with selected item and submit on next tick.
if s.argMode {
s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name)
@@ -190,7 +218,37 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if value != s.lastValue {
s.lastValue = value
lines := strings.Split(value, "\n")
- if len(lines) == 1 && strings.HasPrefix(lines[0], "/") {
+ line := lines[len(lines)-1] // current line (last line for multi-line)
+
+ // Check for @file trigger first.
+ cursorCol := len(line) // approximate: cursor is at end after typing
+ if hasAt, prefix, atIdx := ExtractAtPrefix(line, cursorCol); hasAt && s.cwd != "" {
+ suggestions := GetFileSuggestions(prefix, s.cwd)
+ if len(suggestions) > 0 {
+ s.showPopup = true
+ s.fileMode = true
+ s.argMode = false
+ s.filePrefix = prefix
+ s.fileAtStartIdx = atIdx
+ s.fileSuggestions = suggestions
+ s.fileSynthCmds = make([]SlashCommand, len(suggestions))
+ s.filtered = make([]FuzzyMatch, len(suggestions))
+ for i, fs := range suggestions {
+ name := fs.RelPath
+ desc := ""
+ if fs.IsDir {
+ desc = "directory"
+ }
+ s.fileSynthCmds[i] = SlashCommand{Name: name, Description: desc}
+ s.filtered[i] = FuzzyMatch{Command: &s.fileSynthCmds[i], Score: fs.Score}
+ }
+ s.selected = 0
+ } else {
+ s.showPopup = false
+ s.fileMode = false
+ }
+ } else if len(lines) == 1 && strings.HasPrefix(lines[0], "/") {
+ s.fileMode = false
if !strings.Contains(lines[0], " ") {
// Command name completion.
s.showPopup = true
@@ -210,6 +268,7 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
s.showPopup = false
s.argMode = false
+ s.fileMode = false
}
}
return s, cmd
@@ -335,16 +394,32 @@ func (s *InputComponent) renderPopup() string {
descStyle = descStyle.Foreground(lipgloss.Color("250"))
}
- nameWidth := 15
- name := nameStyle.Width(nameWidth - 2).Render(sc.Name)
+ if s.fileMode {
+ // File mode: use full width for the path, show description
+ // (e.g. "directory") inline after a gap.
+ maxNameLen := s.width - 24
+ displayName := sc.Name
+ if len(displayName) > maxNameLen && maxNameLen > 3 {
+ displayName = displayName[:maxNameLen-3] + "..."
+ }
+ name := nameStyle.Render(displayName)
+ if sc.Description != "" {
+ items = append(items, indicator+name+" "+descStyle.Render(sc.Description))
+ } else {
+ items = append(items, indicator+name)
+ }
+ } else {
+ nameWidth := 15
+ name := nameStyle.Width(nameWidth - 2).Render(sc.Name)
- desc := sc.Description
- maxDescLen := s.width - nameWidth - 14
- if len(desc) > maxDescLen && maxDescLen > 3 {
- desc = desc[:maxDescLen-3] + "..."
+ desc := sc.Description
+ maxDescLen := s.width - nameWidth - 14
+ if len(desc) > maxDescLen && maxDescLen > 3 {
+ desc = desc[:maxDescLen-3] + "..."
+ }
+
+ items = append(items, indicator+name+descStyle.Render(desc))
}
-
- items = append(items, indicator+name+descStyle.Render(desc))
}
if startIdx > 0 {
@@ -404,3 +479,56 @@ func (s *InputComponent) findCommandWithComplete(name string) *SlashCommand {
}
return nil
}
+
+// applyFileCompletion replaces the @prefix in the textarea with the selected
+// file suggestion. For directories, it keeps the popup open for further
+// drilling. For files, it closes the popup and adds a trailing space.
+func (s *InputComponent) applyFileCompletion(idx int) {
+ if idx >= len(s.fileSuggestions) {
+ return
+ }
+
+ suggestion := s.fileSuggestions[idx]
+ value := s.textarea.Value()
+
+ // Build the replacement text. The @ and everything after it up to the
+ // cursor should be replaced with @.
+ // Find the current line's contribution.
+ lines := strings.Split(value, "\n")
+ lastLine := lines[len(lines)-1]
+
+ // Reconstruct: everything before the @ on the last line + @
+ beforeAt := lastLine[:s.fileAtStartIdx]
+ needsQuote := strings.Contains(suggestion.RelPath, " ")
+
+ var replacement string
+ if needsQuote {
+ replacement = `@"` + suggestion.RelPath + `"`
+ } else {
+ replacement = "@" + suggestion.RelPath
+ }
+
+ // For files, add a trailing space. For directories, don't — allow
+ // continued drilling into the directory.
+ if !suggestion.IsDir {
+ replacement += " "
+ }
+
+ newLastLine := beforeAt + replacement
+
+ // Reconstruct the full value with the updated last line.
+ lines[len(lines)-1] = newLastLine
+ newValue := strings.Join(lines, "\n")
+
+ s.textarea.SetValue(newValue)
+ s.textarea.CursorEnd()
+
+ if suggestion.IsDir {
+ // Keep popup open — trigger a refresh for the new directory.
+ s.lastValue = "" // force re-evaluation on next update tick
+ } else {
+ s.showPopup = false
+ s.fileMode = false
+ s.selected = 0
+ }
+}
diff --git a/internal/ui/model.go b/internal/ui/model.go
index 281da467..f047c4fe 100644
--- a/internal/ui/model.go
+++ b/internal/ui/model.go
@@ -201,6 +201,10 @@ type AppModelOptions struct {
// (e.g. GPU fallback info). Displayed at startup when non-empty.
LoadingMessage string
+ // Cwd is the working directory for @file autocomplete and path resolution.
+ // If empty, @file features are disabled.
+ Cwd string
+
// Width is the initial terminal width in columns.
Width int
@@ -449,6 +453,9 @@ type AppModel struct {
// so the model can return to it when the overlay completes.
preOverlayState appState
+ // cwd is the working directory for @file path resolution.
+ cwd string
+
// width and height track the terminal dimensions.
width int
height int
@@ -526,6 +533,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
serverNames: opts.ServerNames,
toolNames: opts.ToolNames,
usageTracker: opts.UsageTracker,
+ cwd: opts.Cwd,
width: width,
height: height,
}
@@ -552,6 +560,11 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
// Wire up child components now that we have the concrete implementations.
m.input = NewInputComponent(width, "Enter your prompt (Type /help for commands, Ctrl+C to quit)", appCtrl)
+ // Wire up cwd for @file autocomplete.
+ if ic, ok := m.input.(*InputComponent); ok && opts.Cwd != "" {
+ ic.SetCwd(opts.Cwd)
+ }
+
// Merge extension commands into the InputComponent's autocomplete source.
if ic, ok := m.input.(*InputComponent); ok && len(opts.ExtensionCommands) > 0 {
for _, ec := range opts.ExtensionCommands {
@@ -898,12 +911,20 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// Regular prompt — forward to the app layer.
+ // Preprocess @file references: expand them into XML-wrapped file
+ // content before sending to the agent. The display text (shown in
+ // scrollback) uses the original user text so the UI stays clean.
+ processedText := msg.Text
+ if m.cwd != "" {
+ processedText = ProcessFileAttachments(msg.Text, m.cwd)
+ }
+
if m.appCtrl != nil {
// Run returns the queue depth: >0 means the prompt was queued
// (agent is busy). We update queuedMessages directly here
// instead of relying on an event from prog.Send(), which would
// deadlock when called synchronously from within Update().
- if qLen := m.appCtrl.Run(msg.Text); qLen > 0 {
+ if qLen := m.appCtrl.Run(processedText); qLen > 0 {
// Queued: anchor the message text above the input with a
// "queued" badge. It will be printed to scrollback when
// the agent picks it up (on QueueUpdatedEvent).