detached fb08ad3

feat(tui): apply .gitignore in both @-picker modes + add respect_gitignore toggle

Previously gitignore filtering ran only in recursive mode; the default
flat directory browse showed .git/, node_modules/, etc. Apply it in
both modes and make it user-controllable.

- Flat scan() now also skips .git and gitignored entries.
- New respectGitignore flag on the suggester (default on), persisted as
  respect_gitignore in config.json, surfaced as a /settings checkbox,
  and plumbed through SettingsStore/InteractiveConfig/cli. Toggling
  flips the picker live.
- .git is always pruned in recursive mode regardless of the toggle, to
  protect the entry budget.
- Tests for flat-mode filtering and the toggle across both modes.
commit
fb08ad382b82a1e0090df222c8358e6b57e09603
author
Raymond Gasper · · 2026-06-09 19:57 UTC
parent
3ce9c28
signature
unsigned
6 files · +167 -9
M packages/agent/cli.go +1 -0
@@ -945,6 +945,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
945945 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
946946 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
947947 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
948+ RespectGitignore: initialCfg.RespectGitignore,
948949 ThemeName: initialCfg.Theme,
949950 ExtensionThemes: extMgr.ThemeOptions,
950951 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
@@ -945,6 +945,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
945 InlineImagesEnabled: initialCfg.InlineImagesEnabled,945 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
946 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,946 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
947 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,947 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
948+ RespectGitignore: initialCfg.RespectGitignore,
948 ThemeName: initialCfg.Theme,949 ThemeName: initialCfg.Theme,
949 ExtensionThemes: extMgr.ThemeOptions,950 ExtensionThemes: extMgr.ThemeOptions,
950 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,951 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
M packages/agent/config.go +6 -0
@@ -38,6 +38,12 @@ type Config struct {
3838 // directory browse. Toggle from /settings.
3939 RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"`
4040
41+ // RespectGitignore controls whether the @-mention file picker hides
42+ // files and directories matched by the project's root .gitignore (in
43+ // both flat and recursive modes). nil/missing means the default,
44+ // which is on; false shows ignored entries. Toggle from /settings.
45+ RespectGitignore *bool `json:"respect_gitignore,omitempty"`
46+
4147 // LastChangelogShown is the version whose release-notes
4248 // dialog the user has already seen. When the running binary's
4349 // version differs, the next interactive run shows the
@@ -38,6 +38,12 @@ type Config struct {
38 // directory browse. Toggle from /settings.38 // directory browse. Toggle from /settings.
39 RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"`39 RecursiveFileSuggest *bool `json:"recursive_file_suggest,omitempty"`
40 40
41+ // RespectGitignore controls whether the @-mention file picker hides
42+ // files and directories matched by the project's root .gitignore (in
43+ // both flat and recursive modes). nil/missing means the default,
44+ // which is on; false shows ignored entries. Toggle from /settings.
45+ RespectGitignore *bool `json:"respect_gitignore,omitempty"`
46+
41 // LastChangelogShown is the version whose release-notes47 // LastChangelogShown is the version whose release-notes
42 // dialog the user has already seen. When the running binary's48 // dialog the user has already seen. When the running binary's
43 // version differs, the next interactive run shows the49 // version differs, the next interactive run shows the
M packages/agent/modes/file_suggest.go +49 -9
@@ -41,8 +41,13 @@ type fileSuggester struct {
4141 // directory-by-directory browsing. Mirrors the persisted
4242 // recursive_file_suggest setting; toggled live from /settings.
4343 recursive bool
44- cachedDir string // absolute directory we last scanned
45- cachedAll []fileEntry
44+ // respectGitignore drops entries matched by the project's root
45+ // .gitignore (and always .git) from both the flat and recursive
46+ // listings. Mirrors the persisted respect_gitignore setting; on by
47+ // default. Toggled live from /settings.
48+ respectGitignore bool
49+ cachedDir string // absolute directory we last scanned
50+ cachedAll []fileEntry
4651 // cachedMTime is the mtime of cachedDir at the time of the scan.
4752 // scan() compares the current mtime against this on every call and
4853 // re-reads the directory if it has changed, so files or folders
@@ -58,7 +63,10 @@ type fileEntry struct {
5863 isDir bool
5964 }
6065
61-func newFileSuggester() *fileSuggester { return &fileSuggester{} }
66+// newFileSuggester returns a picker that respects .gitignore by
67+// default. Callers override via SetRespectGitignore once the persisted
68+// setting is known.
69+func newFileSuggester() *fileSuggester { return &fileSuggester{respectGitignore: true} }
6270
6371 // SetCWD updates the project root.
6472 func (s *fileSuggester) SetCWD(cwd string) {
@@ -84,6 +92,18 @@ func (s *fileSuggester) SetRecursive(on bool) {
8492 s.cursor = 0
8593 }
8694
95+// SetRespectGitignore toggles .gitignore filtering for both modes.
96+// Switching drops the cache so the next scan reflects the new state.
97+func (s *fileSuggester) SetRespectGitignore(on bool) {
98+ if s.respectGitignore == on {
99+ return
100+ }
101+ s.respectGitignore = on
102+ s.cachedDir = ""
103+ s.cachedAll = nil
104+ s.cursor = 0
105+}
106+
87107 // browseDir returns the absolute directory currently being browsed.
88108 func (s *fileSuggester) browseDir() string {
89109 if s.browseRel == "" {
@@ -116,6 +136,10 @@ func (s *fileSuggester) scan() []fileEntry {
116136 if err != nil {
117137 return nil
118138 }
139+ var ig *ignore.Gitignore
140+ if s.respectGitignore {
141+ ig = ignore.Load(s.cwd)
142+ }
119143 var all []fileEntry
120144 for _, e := range entries {
121145 name := e.Name()
@@ -123,6 +147,14 @@ func (s *fileSuggester) scan() []fileEntry {
123147 if s.browseRel != "" {
124148 rel = filepath.Join(s.browseRel, name)
125149 }
150+ if s.respectGitignore {
151+ if e.IsDir() && name == alwaysSkipDir {
152+ continue
153+ }
154+ if ig.Match(filepath.ToSlash(rel), e.IsDir()) {
155+ continue
156+ }
157+ }
126158 all = append(all, fileEntry{
127159 name: name,
128160 rel: rel,
@@ -163,7 +195,10 @@ func (s *fileSuggester) scanRecursive() []fileEntry {
163195 return s.cachedAll
164196 }
165197
166- ig := ignore.Load(root)
198+ var ig *ignore.Gitignore
199+ if s.respectGitignore {
200+ ig = ignore.Load(root)
201+ }
167202 var all []fileEntry
168203 rootSep := strings.Count(root, string(os.PathSeparator))
169204 _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
@@ -180,14 +215,19 @@ func (s *fileSuggester) scanRecursive() []fileEntry {
180215 if relErr != nil {
181216 return nil
182217 }
183- // .gitignore patterns are matched against slash-separated paths.
184- if ig.Match(filepath.ToSlash(rel), d.IsDir()) {
185- if d.IsDir() {
186- return filepath.SkipDir
218+ if s.respectGitignore {
219+ // .gitignore patterns are matched against slash-separated paths.
220+ if ig.Match(filepath.ToSlash(rel), d.IsDir()) {
221+ if d.IsDir() {
222+ return filepath.SkipDir
223+ }
224+ return nil
187225 }
188- return nil
189226 }
190227 if d.IsDir() {
228+ // .git is always pruned: it's never a useful @-mention target
229+ // and would otherwise blow the entry budget even when
230+ // gitignore filtering is off.
191231 if d.Name() == alwaysSkipDir {
192232 return filepath.SkipDir
193233 }
@@ -41,8 +41,13 @@ type fileSuggester struct {
41 // directory-by-directory browsing. Mirrors the persisted41 // directory-by-directory browsing. Mirrors the persisted
42 // recursive_file_suggest setting; toggled live from /settings.42 // recursive_file_suggest setting; toggled live from /settings.
43 recursive bool43 recursive bool
44- cachedDir string // absolute directory we last scanned44+ // respectGitignore drops entries matched by the project's root
45- cachedAll []fileEntry45+ // .gitignore (and always .git) from both the flat and recursive
46+ // listings. Mirrors the persisted respect_gitignore setting; on by
47+ // default. Toggled live from /settings.
48+ respectGitignore bool
49+ cachedDir string // absolute directory we last scanned
50+ cachedAll []fileEntry
46 // cachedMTime is the mtime of cachedDir at the time of the scan.51 // cachedMTime is the mtime of cachedDir at the time of the scan.
47 // scan() compares the current mtime against this on every call and52 // scan() compares the current mtime against this on every call and
48 // re-reads the directory if it has changed, so files or folders53 // re-reads the directory if it has changed, so files or folders
@@ -58,7 +63,10 @@ type fileEntry struct {
58 isDir bool63 isDir bool
59 }64 }
60 65
61-func newFileSuggester() *fileSuggester { return &fileSuggester{} }66+// newFileSuggester returns a picker that respects .gitignore by
67+// default. Callers override via SetRespectGitignore once the persisted
68+// setting is known.
69+func newFileSuggester() *fileSuggester { return &fileSuggester{respectGitignore: true} }
62 70
63 // SetCWD updates the project root.71 // SetCWD updates the project root.
64 func (s *fileSuggester) SetCWD(cwd string) {72 func (s *fileSuggester) SetCWD(cwd string) {
@@ -84,6 +92,18 @@ func (s *fileSuggester) SetRecursive(on bool) {
84 s.cursor = 092 s.cursor = 0
85 }93 }
86 94
95+// SetRespectGitignore toggles .gitignore filtering for both modes.
96+// Switching drops the cache so the next scan reflects the new state.
97+func (s *fileSuggester) SetRespectGitignore(on bool) {
98+ if s.respectGitignore == on {
99+ return
100+ }
101+ s.respectGitignore = on
102+ s.cachedDir = ""
103+ s.cachedAll = nil
104+ s.cursor = 0
105+}
106+
87 // browseDir returns the absolute directory currently being browsed.107 // browseDir returns the absolute directory currently being browsed.
88 func (s *fileSuggester) browseDir() string {108 func (s *fileSuggester) browseDir() string {
89 if s.browseRel == "" {109 if s.browseRel == "" {
@@ -116,6 +136,10 @@ func (s *fileSuggester) scan() []fileEntry {
116 if err != nil {136 if err != nil {
117 return nil137 return nil
118 }138 }
139+ var ig *ignore.Gitignore
140+ if s.respectGitignore {
141+ ig = ignore.Load(s.cwd)
142+ }
119 var all []fileEntry143 var all []fileEntry
120 for _, e := range entries {144 for _, e := range entries {
121 name := e.Name()145 name := e.Name()
@@ -123,6 +147,14 @@ func (s *fileSuggester) scan() []fileEntry {
123 if s.browseRel != "" {147 if s.browseRel != "" {
124 rel = filepath.Join(s.browseRel, name)148 rel = filepath.Join(s.browseRel, name)
125 }149 }
150+ if s.respectGitignore {
151+ if e.IsDir() && name == alwaysSkipDir {
152+ continue
153+ }
154+ if ig.Match(filepath.ToSlash(rel), e.IsDir()) {
155+ continue
156+ }
157+ }
126 all = append(all, fileEntry{158 all = append(all, fileEntry{
127 name: name,159 name: name,
128 rel: rel,160 rel: rel,
@@ -163,7 +195,10 @@ func (s *fileSuggester) scanRecursive() []fileEntry {
163 return s.cachedAll195 return s.cachedAll
164 }196 }
165 197
166- ig := ignore.Load(root)198+ var ig *ignore.Gitignore
199+ if s.respectGitignore {
200+ ig = ignore.Load(root)
201+ }
167 var all []fileEntry202 var all []fileEntry
168 rootSep := strings.Count(root, string(os.PathSeparator))203 rootSep := strings.Count(root, string(os.PathSeparator))
169 _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {204 _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
@@ -180,14 +215,19 @@ func (s *fileSuggester) scanRecursive() []fileEntry {
180 if relErr != nil {215 if relErr != nil {
181 return nil216 return nil
182 }217 }
183- // .gitignore patterns are matched against slash-separated paths.218+ if s.respectGitignore {
184- if ig.Match(filepath.ToSlash(rel), d.IsDir()) {219+ // .gitignore patterns are matched against slash-separated paths.
185- if d.IsDir() {220+ if ig.Match(filepath.ToSlash(rel), d.IsDir()) {
186- return filepath.SkipDir221+ if d.IsDir() {
222+ return filepath.SkipDir
223+ }
224+ return nil
187 }225 }
188- return nil
189 }226 }
190 if d.IsDir() {227 if d.IsDir() {
228+ // .git is always pruned: it's never a useful @-mention target
229+ // and would otherwise blow the entry budget even when
230+ // gitignore filtering is off.
191 if d.Name() == alwaysSkipDir {231 if d.Name() == alwaysSkipDir {
192 return filepath.SkipDir232 return filepath.SkipDir
193 }233 }
M packages/agent/modes/file_suggest_test.go +72 -0
@@ -217,6 +217,78 @@ func TestFileSuggesterRecursiveHonorsGitignore(t *testing.T) {
217217 }
218218 }
219219
220+// TestFileSuggesterFlatModeHonorsGitignore verifies the default
221+// directory-by-directory browse also hides gitignored entries and
222+// always hides .git.
223+func TestFileSuggesterFlatModeHonorsGitignore(t *testing.T) {
224+ tmp := t.TempDir()
225+ if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("node_modules/\n*.log\n"), 0o644); err != nil {
226+ t.Fatal(err)
227+ }
228+ if err := os.MkdirAll(filepath.Join(tmp, "node_modules"), 0o755); err != nil {
229+ t.Fatal(err)
230+ }
231+ if err := os.MkdirAll(filepath.Join(tmp, ".git"), 0o755); err != nil {
232+ t.Fatal(err)
233+ }
234+ if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil {
235+ t.Fatal(err)
236+ }
237+ if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil {
238+ t.Fatal(err)
239+ }
240+ s := newFileSuggester()
241+ s.SetCWD(tmp) // flat mode, respectGitignore on by default
242+
243+ all := s.scan()
244+ if containsEntry(all, "node_modules", true) {
245+ t.Fatal("flat scan surfaced gitignored node_modules/")
246+ }
247+ if containsEntry(all, ".git", true) {
248+ t.Fatal("flat scan surfaced .git/")
249+ }
250+ if containsEntry(all, "debug.log", false) {
251+ t.Fatal("flat scan surfaced gitignored *.log file")
252+ }
253+ if !containsEntry(all, "main.go", false) {
254+ t.Fatalf("flat scan missing tracked main.go: %#v", all)
255+ }
256+ // .gitignore itself is not ignored, so it should remain visible.
257+ if !containsEntry(all, ".gitignore", false) {
258+ t.Fatalf("flat scan missing .gitignore: %#v", all)
259+ }
260+}
261+
262+// TestFileSuggesterRespectGitignoreToggle verifies disabling the
263+// setting surfaces gitignored entries in both modes (while .git stays
264+// hidden in recursive mode to protect the entry budget).
265+func TestFileSuggesterRespectGitignoreToggle(t *testing.T) {
266+ tmp := t.TempDir()
267+ if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("dist/\n"), 0o644); err != nil {
268+ t.Fatal(err)
269+ }
270+ if err := os.MkdirAll(filepath.Join(tmp, "dist"), 0o755); err != nil {
271+ t.Fatal(err)
272+ }
273+ s := newFileSuggester()
274+ s.SetCWD(tmp)
275+
276+ // On by default: dist/ hidden.
277+ if containsEntry(s.scan(), "dist", true) {
278+ t.Fatal("dist/ should be hidden while respectGitignore is on")
279+ }
280+ // Toggle off: dist/ visible (flat mode).
281+ s.SetRespectGitignore(false)
282+ if !containsEntry(s.scan(), "dist", true) {
283+ t.Fatal("dist/ should be visible after disabling respectGitignore (flat)")
284+ }
285+ // And in recursive mode.
286+ s.SetRecursive(true)
287+ if !containsEntry(s.scan(), "dist", true) {
288+ t.Fatal("dist/ should be visible after disabling respectGitignore (recursive)")
289+ }
290+}
291+
220292 // TestFileSuggesterToggleResetsCache verifies SetRecursive drops the
221293 // cached scan so the next matches() reflects the new mode.
222294 func TestFileSuggesterToggleResetsCache(t *testing.T) {
@@ -217,6 +217,78 @@ func TestFileSuggesterRecursiveHonorsGitignore(t *testing.T) {
217 }217 }
218 }218 }
219 219
220+// TestFileSuggesterFlatModeHonorsGitignore verifies the default
221+// directory-by-directory browse also hides gitignored entries and
222+// always hides .git.
223+func TestFileSuggesterFlatModeHonorsGitignore(t *testing.T) {
224+ tmp := t.TempDir()
225+ if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("node_modules/\n*.log\n"), 0o644); err != nil {
226+ t.Fatal(err)
227+ }
228+ if err := os.MkdirAll(filepath.Join(tmp, "node_modules"), 0o755); err != nil {
229+ t.Fatal(err)
230+ }
231+ if err := os.MkdirAll(filepath.Join(tmp, ".git"), 0o755); err != nil {
232+ t.Fatal(err)
233+ }
234+ if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil {
235+ t.Fatal(err)
236+ }
237+ if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil {
238+ t.Fatal(err)
239+ }
240+ s := newFileSuggester()
241+ s.SetCWD(tmp) // flat mode, respectGitignore on by default
242+
243+ all := s.scan()
244+ if containsEntry(all, "node_modules", true) {
245+ t.Fatal("flat scan surfaced gitignored node_modules/")
246+ }
247+ if containsEntry(all, ".git", true) {
248+ t.Fatal("flat scan surfaced .git/")
249+ }
250+ if containsEntry(all, "debug.log", false) {
251+ t.Fatal("flat scan surfaced gitignored *.log file")
252+ }
253+ if !containsEntry(all, "main.go", false) {
254+ t.Fatalf("flat scan missing tracked main.go: %#v", all)
255+ }
256+ // .gitignore itself is not ignored, so it should remain visible.
257+ if !containsEntry(all, ".gitignore", false) {
258+ t.Fatalf("flat scan missing .gitignore: %#v", all)
259+ }
260+}
261+
262+// TestFileSuggesterRespectGitignoreToggle verifies disabling the
263+// setting surfaces gitignored entries in both modes (while .git stays
264+// hidden in recursive mode to protect the entry budget).
265+func TestFileSuggesterRespectGitignoreToggle(t *testing.T) {
266+ tmp := t.TempDir()
267+ if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("dist/\n"), 0o644); err != nil {
268+ t.Fatal(err)
269+ }
270+ if err := os.MkdirAll(filepath.Join(tmp, "dist"), 0o755); err != nil {
271+ t.Fatal(err)
272+ }
273+ s := newFileSuggester()
274+ s.SetCWD(tmp)
275+
276+ // On by default: dist/ hidden.
277+ if containsEntry(s.scan(), "dist", true) {
278+ t.Fatal("dist/ should be hidden while respectGitignore is on")
279+ }
280+ // Toggle off: dist/ visible (flat mode).
281+ s.SetRespectGitignore(false)
282+ if !containsEntry(s.scan(), "dist", true) {
283+ t.Fatal("dist/ should be visible after disabling respectGitignore (flat)")
284+ }
285+ // And in recursive mode.
286+ s.SetRecursive(true)
287+ if !containsEntry(s.scan(), "dist", true) {
288+ t.Fatal("dist/ should be visible after disabling respectGitignore (recursive)")
289+ }
290+}
291+
220 // TestFileSuggesterToggleResetsCache verifies SetRecursive drops the292 // TestFileSuggesterToggleResetsCache verifies SetRecursive drops the
221 // cached scan so the next matches() reflects the new mode.293 // cached scan so the next matches() reflects the new mode.
222 func TestFileSuggesterToggleResetsCache(t *testing.T) {294 func TestFileSuggesterToggleResetsCache(t *testing.T) {
M packages/agent/modes/interactive.go +30 -0
@@ -51,6 +51,11 @@ type InteractiveConfig struct {
5151 // whole project tree instead of browsing one directory at a time.
5252 RecursiveFileSuggest *bool
5353
54+ // RespectGitignore mirrors the persisted respect_gitignore flag at
55+ // startup. nil means the default (on); when false the @-mention
56+ // picker shows files matched by the project's root .gitignore.
57+ RespectGitignore *bool
58+
5459 // ThemeName mirrors the persisted config theme value. Empty means auto.
5560 ThemeName string
5661 // ExtensionThemes returns themes bundled with loaded extensions.
@@ -234,6 +239,7 @@ type SettingsStore interface {
234239 SetInlineImages(enabled bool) error
235240 SetAutoSwarm(enabled bool) error
236241 SetRecursiveFileSuggest(enabled bool) error
242+ SetRespectGitignore(enabled bool) error
237243 SetReasoning(level string) error
238244 SetTheme(name string) error
239245 }
@@ -481,6 +487,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
481487 inputHistoryIndex: -1,
482488 }
483489 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
490+ i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
484491 if cfg.Agent != nil {
485492 i.agent = cfg.Agent
486493 i.view.Messages = cfg.Agent.Messages()
@@ -2630,6 +2637,7 @@ func (i *Interactive) openSettingsDialog() {
26302637 }
26312638
26322639 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
2640+ respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
26332641
26342642 reasoningOptions := []settingsOption{
26352643 {value: "", label: "off", desc: "no reasoning"},
@@ -2700,6 +2708,12 @@ func (i *Interactive) openSettingsDialog() {
27002708 desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
27012709 value: recursiveFiles,
27022710 },
2711+ {
2712+ key: "respect_gitignore",
2713+ label: "hide gitignored files in @-picker",
2714+ desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
2715+ value: respectGitignore,
2716+ },
27032717 {
27042718 key: "reasoning",
27052719 label: "thinking level",
@@ -2800,6 +2814,22 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
28002814 i.statusOK = "recursive @-file search " + onOff(value)
28012815 i.statusErr = ""
28022816 i.mu.Unlock()
2817+ case "respect_gitignore":
2818+ val := value
2819+ i.cfg.RespectGitignore = &val
2820+ if i.cfg.SettingsStore != nil {
2821+ if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
2822+ i.mu.Lock()
2823+ i.statusErr = "settings: " + err.Error()
2824+ i.mu.Unlock()
2825+ return
2826+ }
2827+ }
2828+ i.fileSuggest.SetRespectGitignore(value)
2829+ i.mu.Lock()
2830+ i.statusOK = "hide gitignored files in @-picker " + onOff(value)
2831+ i.statusErr = ""
2832+ i.mu.Unlock()
28032833 }
28042834 }
28052835
@@ -51,6 +51,11 @@ type InteractiveConfig struct {
51 // whole project tree instead of browsing one directory at a time.51 // whole project tree instead of browsing one directory at a time.
52 RecursiveFileSuggest *bool52 RecursiveFileSuggest *bool
53 53
54+ // RespectGitignore mirrors the persisted respect_gitignore flag at
55+ // startup. nil means the default (on); when false the @-mention
56+ // picker shows files matched by the project's root .gitignore.
57+ RespectGitignore *bool
58+
54 // ThemeName mirrors the persisted config theme value. Empty means auto.59 // ThemeName mirrors the persisted config theme value. Empty means auto.
55 ThemeName string60 ThemeName string
56 // ExtensionThemes returns themes bundled with loaded extensions.61 // ExtensionThemes returns themes bundled with loaded extensions.
@@ -234,6 +239,7 @@ type SettingsStore interface {
234 SetInlineImages(enabled bool) error239 SetInlineImages(enabled bool) error
235 SetAutoSwarm(enabled bool) error240 SetAutoSwarm(enabled bool) error
236 SetRecursiveFileSuggest(enabled bool) error241 SetRecursiveFileSuggest(enabled bool) error
242+ SetRespectGitignore(enabled bool) error
237 SetReasoning(level string) error243 SetReasoning(level string) error
238 SetTheme(name string) error244 SetTheme(name string) error
239 }245 }
@@ -481,6 +487,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
481 inputHistoryIndex: -1,487 inputHistoryIndex: -1,
482 }488 }
483 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)489 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
490+ i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
484 if cfg.Agent != nil {491 if cfg.Agent != nil {
485 i.agent = cfg.Agent492 i.agent = cfg.Agent
486 i.view.Messages = cfg.Agent.Messages()493 i.view.Messages = cfg.Agent.Messages()
@@ -2630,6 +2637,7 @@ func (i *Interactive) openSettingsDialog() {
2630 }2637 }
2631 2638
2632 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest2639 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
2640+ respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
2633 2641
2634 reasoningOptions := []settingsOption{2642 reasoningOptions := []settingsOption{
2635 {value: "", label: "off", desc: "no reasoning"},2643 {value: "", label: "off", desc: "no reasoning"},
@@ -2700,6 +2708,12 @@ func (i *Interactive) openSettingsDialog() {
2700 desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",2708 desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
2701 value: recursiveFiles,2709 value: recursiveFiles,
2702 },2710 },
2711+ {
2712+ key: "respect_gitignore",
2713+ label: "hide gitignored files in @-picker",
2714+ desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
2715+ value: respectGitignore,
2716+ },
2703 {2717 {
2704 key: "reasoning",2718 key: "reasoning",
2705 label: "thinking level",2719 label: "thinking level",
@@ -2800,6 +2814,22 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
2800 i.statusOK = "recursive @-file search " + onOff(value)2814 i.statusOK = "recursive @-file search " + onOff(value)
2801 i.statusErr = ""2815 i.statusErr = ""
2802 i.mu.Unlock()2816 i.mu.Unlock()
2817+ case "respect_gitignore":
2818+ val := value
2819+ i.cfg.RespectGitignore = &val
2820+ if i.cfg.SettingsStore != nil {
2821+ if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
2822+ i.mu.Lock()
2823+ i.statusErr = "settings: " + err.Error()
2824+ i.mu.Unlock()
2825+ return
2826+ }
2827+ }
2828+ i.fileSuggest.SetRespectGitignore(value)
2829+ i.mu.Lock()
2830+ i.statusOK = "hide gitignored files in @-picker " + onOff(value)
2831+ i.statusErr = ""
2832+ i.mu.Unlock()
2803 }2833 }
2804 }2834 }
2805 2835
M packages/agent/settings_store.go +9 -0
@@ -31,6 +31,15 @@ func (configSettingsStore) SetRecursiveFileSuggest(enabled bool) error {
3131 return SaveConfig(cfg)
3232 }
3333
34+func (configSettingsStore) SetRespectGitignore(enabled bool) error {
35+ cfg, err := LoadConfig()
36+ if err != nil {
37+ return err
38+ }
39+ cfg.RespectGitignore = &enabled
40+ return SaveConfig(cfg)
41+}
42+
3443 func (configSettingsStore) SetReasoning(level string) error {
3544 cfg, err := LoadConfig()
3645 if err != nil {
@@ -31,6 +31,15 @@ func (configSettingsStore) SetRecursiveFileSuggest(enabled bool) error {
31 return SaveConfig(cfg)31 return SaveConfig(cfg)
32 }32 }
33 33
34+func (configSettingsStore) SetRespectGitignore(enabled bool) error {
35+ cfg, err := LoadConfig()
36+ if err != nil {
37+ return err
38+ }
39+ cfg.RespectGitignore = &enabled
40+ return SaveConfig(cfg)
41+}
42+
34 func (configSettingsStore) SetReasoning(level string) error {43 func (configSettingsStore) SetReasoning(level string) error {
35 cfg, err := LoadConfig()44 cfg, err := LoadConfig()
36 if err != nil {45 if err != nil {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close