From b8f8ab480d0669ed4aaef8c3f4e2ace11a099e72 Mon Sep 17 00:00:00 2001 From: tdwd Date: Mon, 3 Aug 2026 19:24:38 +0200 Subject: [PATCH] Add inline @ file-path completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type @ at a word boundary to open a live-filtering file menu that floats just above the prompt: ↑/↓ to move, tab/↵ to insert, esc to dismiss. The chosen "@path" is inserted with its leading @ on purpose — the claude subprocess expands @relative/path into the file's contents even over the headless stream-json input path (verified by probing: no Read tool_use, content injected), so this is real mention support, not just path-typing. The menu is non-modal (keystrokes fall through to the textarea; a tail syncCompletion re-derives the @-token under the cursor), git-aware (git ls-files, respecting .gitignore, with a directory-walk fallback), and reuses the picker's fuzzy scorer. Rendered via a new anchored placeOverlayAt sibling of the centered placeOverlay. New: complete.go, repofiles.go, complete_test.go. Docs: CLAUDE.md architecture note. --- CLAUDE.md | 4 + commands.go | 1 + complete.go | 248 +++++++++++++++++++++++++++++++++++++++++++++++ complete_test.go | 192 ++++++++++++++++++++++++++++++++++++ keys.go | 9 ++ model.go | 24 +++-- overlay.go | 43 +++++--- repofiles.go | 68 +++++++++++++ update.go | 7 +- view.go | 11 +++ 10 files changed, 580 insertions(+), 27 deletions(-) create mode 100644 complete.go create mode 100644 complete_test.go create mode 100644 repofiles.go diff --git a/CLAUDE.md b/CLAUDE.md index 2bea5f9..325c9c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,10 @@ The transcript is stored as a `[]entry` of raw text/data, not pre-rendered strin The BBS look (leet/studly/ornament/scene-divider helpers) is applied to chrome only — banner, dividers, status, labels, splash. Claude's replies and the diff body stay plain and readable. Don't sprinkle `leet()`/`studly()` into transcript content. Theming is the `palettes` map in `theme.go` (11 built-in themes, ten colors each, switched live via `/theme` and persisted); add a theme by adding a palette row + a `themes` entry — every style rebuilds from the active palette in `buildStyles`. The wordmark is `appName` in `theme.go` (rendered `cath0d3`), and the splash shows a random pick from `logoVariants` in `logos.go` (regenerate a row with `figlet -f -w 200 "cath0d3" | tr '\140' "'"`). The marketing SVGs and per-theme shots in `assets/` regenerate from live UI code via `CATHODE_GENASSETS=1 go test -run 'TestGenerateAssets|TestGenerateThemeAssets'` — regenerate them whenever chrome the preview shows (status bar, banner, diff card) changes. +### Inline `@` file completion (`complete.go`, `repofiles.go`) + +Typing `@` at a word boundary opens a live-filtering file menu that floats just above the prompt. Unlike the modal `picker`, it's non-modal: keystrokes fall through to the focused textarea and `update.go`'s tail calls `syncCompletion`, which re-derives the `@`-token under the cursor (`atToken`) and opens/updates/closes the menu. `keys.go` only intercepts navigation while it's open (↑/↓, tab/↵ to accept, esc to dismiss) — so Enter inserts a path instead of submitting the turn. Accepting writes `@path ` over the typed token; the leading `@` is deliberate — the `claude` subprocess **expands `@relative/path` into the file's contents** even over headless stream-json (verified by probing the input path — no `Read` tool_use, content injected), so this is real mention support, not just path-typing. The file source is `git ls-files --cached --others --exclude-standard` (respects `.gitignore`), falling back to a directory walk outside a repo; it's a package-var (`loadRepoFiles`) so tests stub it. The menu renders via `placeOverlayAt` (the anchored sibling of the centered `placeOverlay`). Two caveats live in code comments: the cursor column is recovered from bubbles' `LineInfo` (exact for ASCII), and accepting a mid-line token on a non-final line leaves the cursor at input-end. + ## Flags worth knowing - `-mode ask|plan|build|bypass` → `claude --permission-mode default|plan|acceptEdits|bypassPermissions` (mapped in `main.go:modeToPermission`) diff --git a/commands.go b/commands.go index d7bfab1..4d0043e 100644 --- a/commands.go +++ b/commands.go @@ -314,6 +314,7 @@ func helpText() string { var b strings.Builder b.WriteString("keybindings:\n") b.WriteString(" enter send · alt+enter / ctrl+j / \\↵ insert a line break\n") + b.WriteString(" @ inline file picker — inserts @path (claude expands it to file contents)\n") b.WriteString(" shift+tab cycle mode (plan → ask → build)\n") b.WriteString(" ctrl+r resume a session\n") b.WriteString(" ctrl+t slash command palette\n") diff --git a/complete.go b/complete.go new file mode 100644 index 0000000..ad6e8a8 --- /dev/null +++ b/complete.go @@ -0,0 +1,248 @@ +package main + +import ( + "sort" + "strings" + "unicode" + + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +// completionRows is how many file rows the @-menu shows before it windows. +const completionRows = 8 + +// completion is the inline @-mention file picker that floats above the prompt. +// Unlike the modal `picker`, it lives alongside the focused textarea: you keep +// typing and it filters live, with ↑/↓ to move and tab/enter to insert. The +// chosen "@path" is expanded to the file's contents by the claude subprocess +// (verified against the stream-json input path), so this is real mention +// support, not just a path-typing shortcut. items is the candidate list loaded +// once when the menu opens; filtered is the fuzzy-ranked subset for the query. +type completion struct { + query string + items []string + filtered []int + cursor int +} + +func (c *completion) setQuery(q string) { + c.query = q + c.refilter() +} + +// refilter reranks items for the current query, reusing the picker's fuzzy +// scorer. An empty query keeps the loaded (alphabetical) order. +func (c *completion) refilter() { + c.filtered = c.filtered[:0] + if c.query == "" { + for i := range c.items { + c.filtered = append(c.filtered, i) + } + c.clampCursor() + return + } + type scored struct{ idx, score int } + ranked := make([]scored, 0, len(c.items)) + for i, it := range c.items { + if s, ok := fuzzyScore(c.query, it); ok { + ranked = append(ranked, scored{i, s}) + } + } + sort.SliceStable(ranked, func(a, b int) bool { return ranked[a].score > ranked[b].score }) + for _, s := range ranked { + c.filtered = append(c.filtered, s.idx) + } + c.clampCursor() +} + +func (c *completion) clampCursor() { + if c.cursor < 0 || c.cursor >= len(c.filtered) { + c.cursor = 0 + } +} + +// move advances the cursor by d, wrapping around the filtered list. +func (c *completion) move(d int) { + if len(c.filtered) == 0 { + return + } + c.cursor = (c.cursor + d + len(c.filtered)) % len(c.filtered) +} + +func (c *completion) selected() (string, bool) { + if len(c.filtered) == 0 { + return "", false + } + return c.items[c.filtered[c.cursor]], true +} + +// atToken finds an active @-mention under the cursor on a single line. Given the +// line and the cursor's rune column, it scans back to the nearest '@' that +// begins a word (start-of-line or after whitespace) and returns the text between +// it and the cursor. ok is false when the cursor isn't inside such a token — no +// '@', an '@' mid-word (e.g. an email address), or a space that already closed +// the token. at is the rune index of the '@'. +func atToken(line string, col int) (query string, at int, ok bool) { + runes := []rune(line) + if col > len(runes) { + col = len(runes) + } + if col < 0 { + col = 0 + } + for i := col - 1; i >= 0; i-- { + r := runes[i] + if unicode.IsSpace(r) { + return "", 0, false + } + if r == '@' { + if i == 0 || unicode.IsSpace(runes[i-1]) { + return string(runes[i+1 : col]), i, true + } + return "", 0, false + } + } + return "", 0, false +} + +// promptCursor returns the textarea cursor as a (hard-line row, rune column). +// bubbles exposes the row (Line) but not the column, so we recover it from +// LineInfo: StartColumn is where the current soft-wrapped row begins in the hard +// line and CharOffset is the cursor's offset within it. Exact for the ASCII text +// of file paths and ordinary prompts. +func promptCursor(ta textarea.Model) (row, col int) { + li := ta.LineInfo() + return ta.Line(), li.StartColumn + li.CharOffset +} + +// syncCompletion opens, updates, or closes the @-menu from the current prompt +// text and cursor. Called once per Update after the textarea has handled the +// key, so the token reflects the latest edit. The menu opens the moment an +// @-token appears under the cursor and closes when it's gone; Esc sets +// compDismissed to suppress reopening until the cursor leaves the token. +func (m *model) syncCompletion() { + if m.picker != nil || m.help || m.pending != nil || m.question != nil { + return + } + row, col := promptCursor(m.input) + lines := strings.Split(m.input.Value(), "\n") + line := "" + if row >= 0 && row < len(lines) { + line = lines[row] + } + query, _, ok := atToken(line, col) + if !ok { + m.comp = nil + m.compDismissed = false + return + } + if m.compDismissed { + return + } + if m.comp == nil { + m.comp = &completion{items: loadRepoFiles()} + } + m.comp.setQuery(query) +} + +// acceptCompletion replaces the @-token under the cursor with the selected +// "@path " and closes the menu. The cursor lands just after the inserted path in +// the common case (token at the end of its line); a token with trailing text on +// a non-final line leaves the cursor at the input end, which is close enough. +func (m *model) acceptCompletion() { + path, ok := m.comp.selected() + if !ok { + m.comp = nil + return + } + row, col := promptCursor(m.input) + lines := strings.Split(m.input.Value(), "\n") + if row < 0 || row >= len(lines) { + m.comp = nil + return + } + runes := []rune(lines[row]) + if col > len(runes) { + col = len(runes) + } + _, at, tok := atToken(lines[row], col) + if !tok { + m.comp = nil + return + } + insert := "@" + path + " " + lines[row] = string(runes[:at]) + insert + string(runes[col:]) + m.input.SetValue(strings.Join(lines, "\n")) + // SetValue drops the cursor at the very end of the input. When the token sat + // on the last line, pull it back to just after the inserted path so typing + // continues there. + if row == len(lines)-1 { + m.input.SetCursor(at + len([]rune(insert))) + } + m.comp = nil + m.compDismissed = false +} + +// handleCompletionKey routes a key while the @-menu is open. It captures only +// navigation, accept, and dismiss; everything else (typing, backspace, +// left/right) returns handled=false so it reaches the textarea, after which +// syncCompletion re-derives the query. +func (m model) handleCompletionKey(msg tea.KeyMsg) (model, tea.Cmd, bool) { + switch msg.String() { + case "up", "ctrl+p": + m.comp.move(-1) + return m, nil, true + case "down", "ctrl+n": + m.comp.move(1) + return m, nil, true + case "tab", "enter": + m.acceptCompletion() + return m, nil, true + case "esc": + m.comp = nil + m.compDismissed = true + return m, nil, true + } + return m, nil, false +} + +// View renders the menu as a compact bordered box anchored above the prompt: a +// title, up to completionRows path rows (windowed around the cursor), and a +// hint. Mirrors the picker's CP437 styling so it reads as one UI. +func (c *completion) View(maxW int) string { + w := maxW - 6 + if w < 24 { + w = 24 + } + if w > 72 { + w = 72 + } + var rows []string + if len(c.filtered) == 0 { + rows = append(rows, cDim.Render(" (no matching files)")) + } else { + start := 0 + if c.cursor >= completionRows { + start = c.cursor - completionRows + 1 + } + end := start + completionRows + if end > len(c.filtered) { + end = len(c.filtered) + } + for i := start; i < end; i++ { + path := c.items[c.filtered[i]] + line := " " + path + if i == c.cursor { + line = approveBar.Render(" " + path + " ") + } + rows = append(rows, ansi.Truncate(line, w, "…")) + } + } + title := dTitle.Render(" @ files ") + " " + cDim.Render("↑↓ move · tab/↵ insert · esc") + body := title + "\n" + strings.Join(rows, "\n") + box := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colCyan).Padding(0, 1).Width(w) + return box.Render(body) +} diff --git a/complete_test.go b/complete_test.go new file mode 100644 index 0000000..fcc92a7 --- /dev/null +++ b/complete_test.go @@ -0,0 +1,192 @@ +package main + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +// withStubFiles swaps the git-backed file source for a fixed list so the +// completion tests don't depend on the working tree. Returns a restore func. +func withStubFiles(files []string) func() { + prev := loadRepoFiles + loadRepoFiles = func() []string { return files } + return func() { loadRepoFiles = prev } +} + +func TestAtToken(t *testing.T) { + cases := []struct { + name string + line string + col int + query string + at int + ok bool + }{ + {"bare at", "@", 1, "", 0, true}, + {"at start with path", "@src/main.go", 12, "src/main.go", 0, true}, + {"after space", "see @rea", 8, "rea", 4, true}, + {"email is not a mention", "foo@bar", 7, "", 0, false}, + {"space closed the token", "@foo bar", 8, "", 0, false}, + {"no at at all", "hello", 5, "", 0, false}, + {"nearest at wins", "@a @b", 5, "b", 3, true}, + {"cursor mid-token", "@abcd", 3, "ab", 0, true}, + } + for _, tc := range cases { + q, at, ok := atToken(tc.line, tc.col) + if ok != tc.ok || q != tc.query || (ok && at != tc.at) { + t.Errorf("%s: atToken(%q,%d) = (%q,%d,%v), want (%q,%d,%v)", + tc.name, tc.line, tc.col, q, at, ok, tc.query, tc.at, tc.ok) + } + } +} + +// Typing @ then a query opens the menu and fuzzy-ranks the file list; a cursor +// past the token (a space intervening) keeps it closed. +func TestCompletionOpensAndRanks(t *testing.T) { + defer withStubFiles([]string{"go.mod", "main.go", "keys.go"})() + + m := inputModel("see @ma") + m.syncCompletion() + if m.comp == nil { + t.Fatal("menu should open for @ma") + } + if sel, _ := m.comp.selected(); sel != "main.go" { + t.Errorf("top match = %q, want main.go", sel) + } + + closed := inputModel("see @ma done") + closed.syncCompletion() + if closed.comp != nil { + t.Error("menu should be closed when the cursor is past the token") + } +} + +// Accepting inserts "@path " over the typed @token and closes the menu; the +// leading @ is kept because the claude subprocess expands it to file contents. +func TestAcceptCompletionInsertsAtPath(t *testing.T) { + defer withStubFiles([]string{"go.mod"})() + + m := inputModel("explain @go") + m.syncCompletion() + if m.comp == nil { + t.Fatal("expected the menu to be open") + } + m.acceptCompletion() + if got := m.input.Value(); got != "explain @go.mod " { + t.Errorf("value = %q, want %q", got, "explain @go.mod ") + } + if m.comp != nil { + t.Error("menu should close after accept") + } +} + +// Enter routed through the real key dispatcher accepts the completion rather +// than submitting the turn (cmd is nil, no turn sent). +func TestHandleKeyEnterAcceptsCompletion(t *testing.T) { + defer withStubFiles([]string{"go.mod"})() + + m := inputModel("@go") + m.syncCompletion() + nm, cmd, handled := m.handleKey(tea.KeyMsg{Type: tea.KeyEnter}) + if !handled { + t.Fatal("enter should be handled by the completion menu") + } + if cmd != nil { + t.Error("accepting a completion must not submit a turn") + } + if got := nm.input.Value(); got != "@go.mod " { + t.Errorf("value = %q, want %q", got, "@go.mod ") + } +} + +// Down through the dispatcher moves the menu cursor (and doesn't fall through to +// history recall). +func TestHandleKeyDownNavigatesCompletion(t *testing.T) { + defer withStubFiles([]string{"a.go", "ab.go", "abc.go"})() + + m := inputModel("@a") + m.syncCompletion() + if m.comp == nil || len(m.comp.filtered) != 3 { + t.Fatalf("expected 3 matches for @a, got %v", m.comp) + } + start := m.comp.cursor + nm, _, handled := m.handleKey(tea.KeyMsg{Type: tea.KeyDown}) + if !handled { + t.Fatal("down should be handled by the completion menu") + } + if nm.comp.cursor != (start+1)%3 { + t.Errorf("cursor = %d, want %d", nm.comp.cursor, (start+1)%3) + } +} + +// Esc dismisses the menu and suppresses reopening while the token persists; +// once the token is gone, a fresh @ opens it again. +func TestCompletionEscDismissUntilTokenLeft(t *testing.T) { + defer withStubFiles([]string{"main.go"})() + + m := inputModel("@ma") + m.syncCompletion() + if m.comp == nil { + t.Fatal("menu should be open") + } + nm, _, handled := m.handleCompletionKey(tea.KeyMsg{Type: tea.KeyEsc}) + if !handled || nm.comp != nil || !nm.compDismissed { + t.Fatalf("esc should close and mark dismissed (comp=%v dismissed=%v)", nm.comp, nm.compDismissed) + } + + nm.input.SetValue("@main") + nm.syncCompletion() + if nm.comp != nil { + t.Error("menu should stay dismissed while the token persists") + } + + nm.input.SetValue("hello ") + nm.syncCompletion() + if nm.compDismissed { + t.Error("dismissal should reset once the token is gone") + } + nm.input.SetValue("hello @m") + nm.syncCompletion() + if nm.comp == nil { + t.Error("a fresh @ should reopen the menu") + } +} + +// End-to-end through the real Update loop and View: typing "@k" opens the menu, +// which renders the matching file above the prompt without disturbing the frame +// height (the overlay splices in place). +func TestCompletionMenuRendersAbovePrompt(t *testing.T) { + defer withStubFiles([]string{"go.mod", "main.go", "keys.go"})() + + var tm tea.Model = func() model { m := newModel(&Engine{}, "ask", nil, "bar", ""); m.splash = false; return m }() + tm, _ = tm.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + tm, _ = tm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'@'}}) + tm, _ = tm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + fm := tm.(model) + + if fm.comp == nil { + t.Fatal("menu should be open after typing @k") + } + view := stripANSI(fm.View()) + if !strings.Contains(view, "keys.go") { + t.Fatalf("menu should list keys.go:\n%s", view) + } + if h := strings.Count(view, "\n") + 1; h != 24 { + t.Errorf("View height = %d, want 24 (overlay must splice in place)", h) + } + lines := strings.Split(view, "\n") + menuRow, promptRow := -1, -1 + for i, ln := range lines { + if menuRow < 0 && strings.Contains(ln, "keys.go") { + menuRow = i + } + if strings.Contains(ln, "›") { + promptRow = i + } + } + if menuRow < 0 || promptRow < 0 || menuRow >= promptRow { + t.Errorf("menu (row %d) should render above the prompt (row %d)", menuRow, promptRow) + } +} diff --git a/keys.go b/keys.go index 48ed916..cf192ed 100644 --- a/keys.go +++ b/keys.go @@ -145,6 +145,15 @@ func (m model) handleKey(msg tea.KeyMsg) (model, tea.Cmd, bool) { return m.handleApprovalKey(msg) } + // While the inline @-file menu is open it owns navigation/accept/dismiss so + // Enter inserts a path instead of submitting the turn; everything else falls + // through to the textarea, and syncCompletion re-derives the query (complete.go). + if m.comp != nil { + if nm, cmd, handled := m.handleCompletionKey(msg); handled { + return nm, cmd, true + } + } + // Arrow keys: prompt history, except with mouse capture off (/mouse), where // the terminal turns the wheel into ↑/↓ — there we scroll the transcript so // older output can be brought into view to select. Ctrl-↑/↓ always do diff --git a/model.go b/model.go index 1ff7621..937e930 100644 --- a/model.go +++ b/model.go @@ -62,16 +62,20 @@ type model struct { hist *history sessions *sessionStore - pending *approvalReq // non-nil while awaiting a y/n decision - question *pendingQuestion // non-nil while answering an AskUserQuestion - picker *picker // non-nil while a picker dialog is open - splash bool // true until the first keypress dismisses the boot screen - splashFrame int // current animation frame; clamps at splashFinalFrame - logoIdx int // which splash wordmark variant this launch shows (picked once) - colorPhase int // monotonic counter driving the header wordmark's rainbow sweep - sidebar bool // true to render the BBS info rail (auto-hidden on narrow terms) - help bool // true while the help modal is up; Esc dismisses - mouse bool // mouse capture on (wheel scroll) vs off (terminal-native select/copy) + pending *approvalReq // non-nil while awaiting a y/n decision + question *pendingQuestion // non-nil while answering an AskUserQuestion + picker *picker // non-nil while a picker dialog is open + comp *completion // non-nil while the inline @-file menu is open (complete.go) + // compDismissed suppresses reopening the @-menu after Esc until the cursor + // leaves the current token, so Esc is a real "let me type it myself" escape. + compDismissed bool + splash bool // true until the first keypress dismisses the boot screen + splashFrame int // current animation frame; clamps at splashFinalFrame + logoIdx int // which splash wordmark variant this launch shows (picked once) + colorPhase int // monotonic counter driving the header wordmark's rainbow sweep + sidebar bool // true to render the BBS info rail (auto-hidden on narrow terms) + help bool // true while the help modal is up; Esc dismisses + mouse bool // mouse capture on (wheel scroll) vs off (terminal-native select/copy) settings settings // persisted user config (see settings.go) headerStyle string // live header animation id; previewed in /settings, committed to settings.Header diff --git a/overlay.go b/overlay.go index 35789af..0276115 100644 --- a/overlay.go +++ b/overlay.go @@ -15,19 +15,7 @@ import ( // lipgloss v1 has no overlay primitive, so we do the splice ourselves using // x/ansi for cell-accurate cuts that preserve SGR state across the seam. func placeOverlay(bg, fg string, termW, termH int) string { - bgLines := strings.Split(bg, "\n") - fgLines := strings.Split(fg, "\n") - - // Measure fg by its widest line so the box ends up axis-aligned even when - // inner rows differ in trailing whitespace. - fgW := 0 - for _, l := range fgLines { - if w := lipgloss.Width(l); w > fgW { - fgW = w - } - } - fgH := len(fgLines) - + fgW, fgH := overlaySize(fg) x := (termW - fgW) / 2 if x < 0 { x = 0 @@ -36,20 +24,43 @@ func placeOverlay(bg, fg string, termW, termH int) string { if y < 0 { y = 0 } + return placeOverlayAt(bg, fg, x, y) +} - // Pad bg vertically so the overlay can land even if bg has fewer rows - // than the terminal (rare, but possible on a short transcript). +// placeOverlayAt pastes fg onto bg with its top-left at (x, y). The centered +// placeOverlay is the common case; the inline @-file menu uses this directly to +// anchor itself just above the prompt. +func placeOverlayAt(bg, fg string, x, y int) string { + bgLines := strings.Split(bg, "\n") + fgLines := strings.Split(fg, "\n") + fgW, fgH := overlaySize(fg) + // Pad bg vertically so the overlay can land even if bg has fewer rows than + // the terminal (rare, but possible on a short transcript). for len(bgLines) < y+fgH { bgLines = append(bgLines, "") } - for i, fgLine := range fgLines { row := y + i + if row < 0 { + continue + } bgLines[row] = spliceLine(bgLines[row], fgLine, x, fgW) } return strings.Join(bgLines, "\n") } +// overlaySize measures fg by its widest line (so the box stays axis-aligned even +// when inner rows differ in trailing whitespace) and its line count. +func overlaySize(fg string) (w, h int) { + lines := strings.Split(fg, "\n") + for _, l := range lines { + if lw := lipgloss.Width(l); lw > w { + w = lw + } + } + return w, len(lines) +} + // spliceLine produces `left | fg | right` where left is the first `x` visible // cells of bg, fg is the centered overlay row, and right is everything in bg // past column `x+fgW`. ANSI escape state is preserved on both sides because diff --git a/repofiles.go b/repofiles.go new file mode 100644 index 0000000..69d3f7b --- /dev/null +++ b/repofiles.go @@ -0,0 +1,68 @@ +package main + +import ( + "io/fs" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +// maxRepoFiles bounds how many candidate paths the @-completion loads, so a +// giant working tree can't blow up memory or the fuzzy filter. +const maxRepoFiles = 20000 + +// loadRepoFiles is the @-completion's file source, a package var so tests can +// stub it with a fixed list instead of shelling out to git. +var loadRepoFiles = gitRepoFiles + +// gitRepoFiles lists tracked plus untracked-but-not-ignored paths relative to +// the cwd via `git ls-files`, which respects .gitignore for free (and mirrors +// what Claude Code's own @-picker offers). Outside a git repo, or if git is +// missing, it falls back to a plain directory walk. Paths come NUL-separated so +// names with spaces or newlines survive; the list is sorted for a stable menu +// order on an empty query. +func gitRepoFiles() []string { + out, err := exec.Command("git", "ls-files", "--cached", "--others", "--exclude-standard", "-z").Output() + if err != nil { + return walkFiles(".") + } + files := make([]string, 0, 256) + for _, p := range strings.Split(string(out), "\x00") { + if p == "" { + continue + } + files = append(files, p) + if len(files) >= maxRepoFiles { + break + } + } + sort.Strings(files) + return files +} + +// walkFiles is the non-git fallback: a walk of root that skips VCS and +// dependency dirs so the menu isn't drowned in build output. Best-effort — it +// doesn't read .gitignore, which is why git is preferred. +func walkFiles(root string) []string { + skip := map[string]bool{".git": true, "node_modules": true, "vendor": true, ".venv": true} + var files []string + _ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if skip[d.Name()] { + return filepath.SkipDir + } + return nil + } + files = append(files, strings.TrimPrefix(p, "./")) + if len(files) >= maxRepoFiles { + return fs.SkipAll + } + return nil + }) + sort.Strings(files) + return files +} diff --git a/update.go b/update.go index cc667e9..6d5578f 100644 --- a/update.go +++ b/update.go @@ -156,6 +156,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.input, cmd = m.input.Update(msg) cmds = append(cmds, cmd) + // Reconcile the inline @-file menu with the prompt after the textarea has + // applied the key. Only on typed input (which may open/change it) or while + // it's already open — stream events don't touch the prompt (complete.go). + if _, isKey := msg.(tea.KeyMsg); isKey || m.comp != nil { + m.syncCompletion() + } m.vp, cmd = m.vp.Update(msg) cmds = append(cmds, cmd) // A wheel scroll moved the viewport here (keys are handled above; streaming @@ -217,4 +223,3 @@ func (m *model) armSpinnerIfNeeded() tea.Cmd { } return nil } - diff --git a/view.go b/view.go index ff7a059..2e841e4 100644 --- a/view.go +++ b/view.go @@ -24,6 +24,17 @@ func (m model) View() string { if m.help { return placeOverlay(bg, helpModalView(m.w, m.h), m.w, m.h) } + // The inline @-file menu floats just above the prompt rather than centered: + // its last row sits on the line above the prompt (banner+divider+viewport is + // m.h-1-promptRows tall; status is the final row). See complete.go. + if m.comp != nil { + menu := m.comp.View(m.w) + y := m.h - 1 - m.promptRows() - lipgloss.Height(menu) + if y < 0 { + y = 0 + } + return placeOverlayAt(bg, menu, 2, y) + } return bg }