File Tagging (@) — Implementation Plan¶
Overview¶
Add @-based file tagging to the REPL input. When the user types @ followed by at
least one character, a dropdown appears showing up to 10 matching file paths from the
working directory. The dropdown reuses the existing SuggestionModel infrastructure
(styles, navigation, rendering) to avoid duplication. On selection, the relative path
replaces the @<query> token in the textarea. Multiple @ tags are supported.
Architecture¶
How it works¶
┌──────────────────────────────────────┐
│ viewport │
├──────────────────────────────────────┤
│ > Fix the bug in @src/m │ ← textarea with cursor after @src/m
├──────────────────────────────────────┤
│ src/main.go │ ← file suggestion dropdown (max 10)
│ src/model.go │
│ src/middleware.go │
├──────────────────────────────────────┤
│ model: openai/gpt-4o · ctx: 12% │ ← metadata
└──────────────────────────────────────┘
- The user types
@src/m→ dropdown shows files matchingsrc/mprefix/substring - Up/Down to navigate, Tab/Enter to select, Esc to dismiss
- On selection,
@src/mis replaced with@src/main.go(or the selected path) - The user can type
@again elsewhere to tag another file
Key design decisions¶
| Topic | Decision |
|---|---|
| Trigger | @ character followed by ≥1 character |
| File source | Live glob using doublestar.GlobWalk with Guard.IsBlocked to skip gitignored/blocked dirs — no in-memory cache |
| Includes | Both files and directories |
| Pattern construction | Query foo → glob pattern **/*foo* (case-insensitive via doublestar options or lowercase normalization) |
| Matching | Substring match on file/directory names via glob wildcards; always fresh results (no stale cache) |
| Max results | 10 (stop walking after 10 matches) |
| Dropdown UI | Reuse SuggestionModel rendering logic and theme styles |
| Selection behavior | Replace the @<query> token at cursor with the selected relative path |
| Multiple tags | Each @ token is independent; only the one at/before cursor is active |
Files to create / modify¶
| File | Action | Purpose |
|---|---|---|
internal/cli/repl/filesearch/filesearch.go |
Create | Live glob search using doublestar.GlobWalk with guard filtering |
internal/cli/repl/filesearch/filesearch_test.go |
Create | Unit tests for file search |
internal/cli/repl/widgets/suggestion.go |
Modify | Generalize to support both slash commands and file suggestions |
internal/cli/repl/widgets/suggestion_test.go |
Modify | Add tests for file suggestion mode |
internal/cli/repl/repl.go |
Modify | Initialize file searcher, pass to suggestion model |
internal/cli/repl/handlers.go |
Modify | Detect @ token, switch suggestion source, handle selection replacement |
Granular Todo Items¶
1. Create filesearch/filesearch.go — live glob-based file search¶
- [ ] Define
FileSearcherstruct holdingworkingDir stringandguard *filesystem.Guard - [ ] Implement
NewFileSearcher(workingDir string, guard *filesystem.Guard) *FileSearcher - [ ] Implement
(s *FileSearcher) Search(query string, limit int) []string: - If query is empty, return nil
- Construct glob pattern:
**/*{query}*(wraps the query in wildcards for substring matching) - Use
doublestar.GlobWalk(os.DirFS(workingDir), pattern, ...)to walk the filesystem - Inside the walk callback, skip paths where
guard.IsBlocked(absPath)is true - Collect both file and directory relative paths; stop early after
limitmatches (returnfs.SkipAll) - Return the collected paths
- [ ] Handle special glob characters in the query by escaping them (e.g.
[,],{,}) before constructing the pattern
2. Create filesearch/filesearch_test.go¶
- [ ] Test
Search("")returns nil - [ ] Test
Search("main")returns file and directory paths containing "main" - [ ] Test
Search("xyz_nonexistent")returns empty - [ ] Test limit is respected (query matching many files returns at most
limit) - [ ] Test directories are included in results (not just files)
- [ ] Test special characters in query are handled (e.g.
[,]) - [ ] Use
t.TempDir()with a known directory structure for deterministic tests
3. Generalize SuggestionModel in widgets/suggestion.go¶
The current SuggestionModel is tightly coupled to SlashCommand. We need to
generalize it to support two suggestion sources: slash commands and file paths.
- [ ] Define a
SuggestionIteminterface or struct:type SuggestionItem struct { Name string // display name (command name or file path) Description string // description (command desc or empty for files) } - [ ] Change
SuggestionModel.itemsfrom[]SlashCommandto[]SuggestionItem - [ ] Add a
modefield to track the current suggestion type (commandMode/fileMode) — this determines behavior on selection - [ ] Update
Refreshto accept[]SuggestionItemdirectly (move filtering logic to the caller in handlers.go) - [ ] Alternatively, keep
Refresh(input string)for commands and addRefreshFiles(items []SuggestionItem)— whichever is cleaner - [ ] Ensure
Current()returns*SuggestionItem - [ ] Ensure
View()works correctly for both modes (file items have no description, so the desc column can be omitted or empty) - [ ] Update all callers to use the new types
4. Update handlers.go — detect @ token and handle selection¶
- [ ] Add a helper
extractAtToken(input string, cursorPos int) (token string, startIdx int, found bool): - Scan backwards from cursor position to find the nearest
@ - The token is the text between
@and the cursor (exclusive of@) - Return
found = falseif no@is found or token is empty (< 1 char) - Stop scanning if we hit a space before finding
@(the@must be preceded by a space or be at the start of the input) - [ ] In the key handler fall-through (after textarea update), determine suggestion mode:
- If input starts with
/→ command suggestions (existing behavior) - Else if
extractAtTokenfinds a valid token → file suggestions - Else → hide suggestions
- [ ] On Tab/Enter when file suggestion is visible:
- Get the current textarea value and cursor position
- Use
extractAtTokento find the@tokenrange - Replace
@tokenwith@selected_path(with trailing space) in the textarea value - Set the new value and adjust cursor position
- [ ] On Tab/Enter when command suggestion is visible: keep existing behavior
5. Update repl.go — initialize file searcher¶
- [ ] Add
fileSearcher *filesearch.FileSearcherfield toreplModel - [ ] In
initialModel(), create the file searcher:model.fileSearcher = filesearch.NewFileSearcher(ctx.workingDir, guard) - Note: we need access to a
Guardinstance. The guard is currently created insiderepltooling.SetupToolRegistry. Either:- (a) Create the guard earlier and pass it to both
SetupToolRegistryandNewFileSearcher - (b) Create a separate guard for the file searcher (simple, no permission checking needed)
- (a) Create the guard earlier and pass it to both
- Decision: Option (b) — create a lightweight guard with the same gitignore
awareness just for walking. Or even simpler: reuse the same
filesystem.Guardby extracting its creation. - [ ] Pass
m.fileSearcherto the suggestion refresh logic in handlers
6. Update widgets/suggestion_test.go¶
- [ ] Add tests for
SuggestionItem-based items (file mode) - [ ] Test
View()with items that have no description - [ ] Test
Current()returns correctSuggestionItemin file mode - [ ] Test navigation (MoveUp/MoveDown) works the same for both modes
7. Testing & verification¶
- [ ] Run
go test ./...— all tests pass - [ ] Run
go mod tidy - [ ] Manual smoke test:
- Type
@s→ dropdown shows files and directories containing "s" - Type
@internal/cli→ dropdown narrows to files/dirs in that path - Press Down/Up → navigate suggestions
- Press Tab → selected path replaces
@internal/cliwith@internal/cli/repl/repl.go - Press Enter on a suggestion → same replacement behavior
- Press Esc → dropdown closes
- Type
fix @main.go and @rea→ second@triggers new suggestions /helpstill shows command suggestions (no regression)- After
/clear, typing@still works (no stale state)
Implementation order¶
filesearch/filesearch.go+ tests — standalone, no dependencies on UI- Generalize
SuggestionModel— refactor types, update existing callers extractAtTokenhelper + tests — pure function, easy to test- Wire into
repl.goandhandlers.go— integration - End-to-end manual testing
Open questions¶
- Should the
@pathtoken be stripped before sending to the LLM, or sent as-is? If sent as-is, the LLM sees@src/main.goin the message. This is fine — the LLM can interpret it as a file reference. Alternatively, we could prepend the file contents automatically. Current plan: send as-is (the@prefix is just a UI convenience for typing paths). File content injection can be a follow-up feature.