detached 353da72

feat(tui): @ file picker with directory navigation

Type @ to browse files in the working directory. Up/down navigate, right arrow opens directories, left arrow goes back, enter selects. Files insert as [file:name], directories as [dir:name/]. Drag-dropped folders also show as [dir:] chips. Separate counters for file and dir chips.
commit
353da72d28ae97714d612d108ec335828514d5e3
author
patriceckhart · · 2026-04-25 08:52 UTC
parent
8a38171
signature
unsigned
refs
tag: v0.1.11
3 files · +384 -18
A internal/agent/modes/file_suggest.go +293 -0
@@ -0,0 +1,293 @@
1+package modes
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "regexp"
7+ "sort"
8+ "strings"
9+
10+ "github.com/patriceckhart/zot/internal/tui"
11+)
12+
13+// fileSuggester provides an @-triggered file/directory picker popup.
14+// Type "@" followed by an optional filter to list files in the working
15+// directory. Arrow up/down navigate, enter selects, esc cancels.
16+// Right arrow opens a directory, left arrow goes back to the parent.
17+type fileSuggester struct {
18+ cursor int
19+ lastMatches []fileEntry
20+ cwd string // project root
21+ browseRel string // relative path from cwd we're currently browsing ("" = cwd itself)
22+ cachedDir string // absolute directory we last scanned
23+ cachedAll []fileEntry
24+}
25+
26+type fileEntry struct {
27+ name string // display name
28+ rel string // path relative to cwd (used for chip insertion)
29+ isDir bool
30+}
31+
32+func newFileSuggester() *fileSuggester { return &fileSuggester{} }
33+
34+// SetCWD updates the project root.
35+func (s *fileSuggester) SetCWD(cwd string) {
36+ if s.cwd != cwd {
37+ s.cwd = cwd
38+ s.browseRel = ""
39+ s.cachedDir = ""
40+ s.cachedAll = nil
41+ }
42+}
43+
44+// browseDir returns the absolute directory currently being browsed.
45+func (s *fileSuggester) browseDir() string {
46+ if s.browseRel == "" {
47+ return s.cwd
48+ }
49+ return filepath.Join(s.cwd, s.browseRel)
50+}
51+
52+// scan reads entries from the current browse directory (cached).
53+func (s *fileSuggester) scan() []fileEntry {
54+ dir := s.browseDir()
55+ if s.cachedDir == dir && s.cachedAll != nil {
56+ return s.cachedAll
57+ }
58+ entries, err := os.ReadDir(dir)
59+ if err != nil {
60+ return nil
61+ }
62+ var all []fileEntry
63+ for _, e := range entries {
64+ name := e.Name()
65+ rel := name
66+ if s.browseRel != "" {
67+ rel = filepath.Join(s.browseRel, name)
68+ }
69+ all = append(all, fileEntry{
70+ name: name,
71+ rel: rel,
72+ isDir: e.IsDir(),
73+ })
74+ }
75+ sort.Slice(all, func(i, j int) bool {
76+ if all[i].isDir != all[j].isDir {
77+ return all[i].isDir
78+ }
79+ return strings.ToLower(all[i].name) < strings.ToLower(all[j].name)
80+ })
81+ s.cachedAll = all
82+ s.cachedDir = dir
83+ return all
84+}
85+
86+// extractAtQuery returns the filter string after "@".
87+func extractAtQuery(input string) (string, bool) {
88+ input = strings.TrimRight(input, " ")
89+ idx := strings.LastIndex(input, "@")
90+ if idx < 0 {
91+ return "", false
92+ }
93+ if idx > 0 && input[idx-1] != ' ' {
94+ return "", false
95+ }
96+ query := input[idx+1:]
97+ if strings.ContainsAny(query, " \t\n") {
98+ return "", false
99+ }
100+ return query, true
101+}
102+
103+// matches returns file entries matching the current @-query.
104+func (s *fileSuggester) matches(input string) []fileEntry {
105+ query, ok := extractAtQuery(input)
106+ if !ok {
107+ return nil
108+ }
109+ all := s.scan()
110+ if len(all) == 0 {
111+ return nil
112+ }
113+ needle := strings.ToLower(query)
114+ if needle == "" {
115+ return all
116+ }
117+ var out []fileEntry
118+ for _, e := range all {
119+ if strings.Contains(strings.ToLower(e.name), needle) {
120+ out = append(out, e)
121+ }
122+ }
123+ return out
124+}
125+
126+// Active reports whether the popup should be visible.
127+func (s *fileSuggester) Active(input string) bool {
128+ return len(s.matches(input)) > 0
129+}
130+
131+// Reset clears state.
132+func (s *fileSuggester) Reset() {
133+ s.cursor = 0
134+ s.lastMatches = nil
135+ s.browseRel = ""
136+ s.cachedDir = ""
137+ s.cachedAll = nil
138+}
139+
140+// Invalidate forces a rescan.
141+func (s *fileSuggester) Invalidate() {
142+ s.cachedDir = ""
143+ s.cachedAll = nil
144+}
145+
146+func (s *fileSuggester) Up() {
147+ if s.cursor > 0 {
148+ s.cursor--
149+ }
150+}
151+
152+func (s *fileSuggester) Down() {
153+ if s.cursor < len(s.lastMatches)-1 {
154+ s.cursor++
155+ }
156+}
157+
158+// Right opens the selected directory, descending into it.
159+// Returns true if a directory was entered.
160+func (s *fileSuggester) Right() bool {
161+ m := s.lastMatches
162+ if len(m) == 0 || s.cursor >= len(m) {
163+ return false
164+ }
165+ e := m[s.cursor]
166+ if !e.isDir {
167+ return false
168+ }
169+ s.browseRel = e.rel
170+ s.cachedDir = ""
171+ s.cachedAll = nil
172+ s.cursor = 0
173+ return true
174+}
175+
176+// Left goes back to the parent directory.
177+// Returns true if we moved up.
178+func (s *fileSuggester) Left() bool {
179+ if s.browseRel == "" {
180+ return false
181+ }
182+ parent := filepath.Dir(s.browseRel)
183+ if parent == "." {
184+ parent = ""
185+ }
186+ s.browseRel = parent
187+ s.cachedDir = ""
188+ s.cachedAll = nil
189+ s.cursor = 0
190+ return true
191+}
192+
193+// Selection returns the relative path of the currently highlighted entry.
194+func (s *fileSuggester) Selection(input string) string {
195+ m := s.matches(input)
196+ if len(m) == 0 {
197+ return ""
198+ }
199+ if s.cursor >= len(m) {
200+ s.cursor = len(m) - 1
201+ }
202+ return m[s.cursor].rel
203+}
204+
205+// SelectedEntry returns the currently highlighted entry.
206+func (s *fileSuggester) SelectedEntry(input string) (fileEntry, bool) {
207+ m := s.matches(input)
208+ if len(m) == 0 {
209+ return fileEntry{}, false
210+ }
211+ if s.cursor >= len(m) {
212+ s.cursor = len(m) - 1
213+ }
214+ return m[s.cursor], true
215+}
216+
217+// Render returns the popup lines.
218+func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string {
219+ m := s.matches(input)
220+ if len(m) == 0 {
221+ return nil
222+ }
223+ s.lastMatches = m
224+ if s.cursor >= len(m) {
225+ s.cursor = len(m) - 1
226+ }
227+
228+ const maxVisible = 12
229+ visible := m
230+ offset := 0
231+ if len(m) > maxVisible {
232+ if s.cursor >= maxVisible {
233+ offset = s.cursor - maxVisible + 1
234+ }
235+ end := offset + maxVisible
236+ if end > len(m) {
237+ end = len(m)
238+ offset = end - maxVisible
239+ }
240+ visible = m[offset:end]
241+ }
242+
243+ var out []string
244+
245+ // Show breadcrumb when browsing a subdirectory.
246+ if s.browseRel != "" {
247+ crumb := th.FG256(th.Accent, " "+s.browseRel+"/")
248+ out = append(out, crumb)
249+ }
250+
251+ for i, e := range visible {
252+ idx := offset + i
253+ name := e.name
254+ if e.isDir {
255+ name += "/"
256+ }
257+ plain := " " + name
258+ if idx == s.cursor {
259+ out = append(out, th.PadHighlight(plain, width))
260+ } else {
261+ out = append(out, th.FG256(th.Muted, plain))
262+ }
263+ }
264+
265+ out = append(out, "")
266+ hint := " \u2191/\u2193 navigate - enter select - esc cancel"
267+ if s.browseRel != "" {
268+ hint = " \u2191/\u2193 navigate - \u2192 open - \u2190 back - enter select - esc cancel"
269+ } else {
270+ hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel"
271+ }
272+ out = append(out, th.FG256(th.Muted, hint))
273+ out = append(out, "")
274+ return out
275+}
276+
277+// fileChipRE matches [file:N:name] and [dir:N:name/] chips.
278+var fileChipRE = regexp.MustCompile(`\[(file|dir):(\d+):[^\]]+\]`)
279+
280+// expandFileChips replaces [file:name] and [dir:name/] chips with
281+// the full path relative to cwd.
282+func expandFileChips(text, cwd string) string {
283+ return fileChipRE.ReplaceAllStringFunc(text, func(match string) string {
284+ groups := fileChipRE.FindStringSubmatch(match)
285+ if len(groups) < 3 {
286+ return match
287+ }
288+ name := groups[2]
289+ // Strip trailing slash from dir names.
290+ name = strings.TrimRight(name, "/")
291+ return filepath.Join(cwd, name)
292+ })
293+}
@@ -0,0 +1,293 @@
1+package modes
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "regexp"
7+ "sort"
8+ "strings"
9+
10+ "github.com/patriceckhart/zot/internal/tui"
11+)
12+
13+// fileSuggester provides an @-triggered file/directory picker popup.
14+// Type "@" followed by an optional filter to list files in the working
15+// directory. Arrow up/down navigate, enter selects, esc cancels.
16+// Right arrow opens a directory, left arrow goes back to the parent.
17+type fileSuggester struct {
18+ cursor int
19+ lastMatches []fileEntry
20+ cwd string // project root
21+ browseRel string // relative path from cwd we're currently browsing ("" = cwd itself)
22+ cachedDir string // absolute directory we last scanned
23+ cachedAll []fileEntry
24+}
25+
26+type fileEntry struct {
27+ name string // display name
28+ rel string // path relative to cwd (used for chip insertion)
29+ isDir bool
30+}
31+
32+func newFileSuggester() *fileSuggester { return &fileSuggester{} }
33+
34+// SetCWD updates the project root.
35+func (s *fileSuggester) SetCWD(cwd string) {
36+ if s.cwd != cwd {
37+ s.cwd = cwd
38+ s.browseRel = ""
39+ s.cachedDir = ""
40+ s.cachedAll = nil
41+ }
42+}
43+
44+// browseDir returns the absolute directory currently being browsed.
45+func (s *fileSuggester) browseDir() string {
46+ if s.browseRel == "" {
47+ return s.cwd
48+ }
49+ return filepath.Join(s.cwd, s.browseRel)
50+}
51+
52+// scan reads entries from the current browse directory (cached).
53+func (s *fileSuggester) scan() []fileEntry {
54+ dir := s.browseDir()
55+ if s.cachedDir == dir && s.cachedAll != nil {
56+ return s.cachedAll
57+ }
58+ entries, err := os.ReadDir(dir)
59+ if err != nil {
60+ return nil
61+ }
62+ var all []fileEntry
63+ for _, e := range entries {
64+ name := e.Name()
65+ rel := name
66+ if s.browseRel != "" {
67+ rel = filepath.Join(s.browseRel, name)
68+ }
69+ all = append(all, fileEntry{
70+ name: name,
71+ rel: rel,
72+ isDir: e.IsDir(),
73+ })
74+ }
75+ sort.Slice(all, func(i, j int) bool {
76+ if all[i].isDir != all[j].isDir {
77+ return all[i].isDir
78+ }
79+ return strings.ToLower(all[i].name) < strings.ToLower(all[j].name)
80+ })
81+ s.cachedAll = all
82+ s.cachedDir = dir
83+ return all
84+}
85+
86+// extractAtQuery returns the filter string after "@".
87+func extractAtQuery(input string) (string, bool) {
88+ input = strings.TrimRight(input, " ")
89+ idx := strings.LastIndex(input, "@")
90+ if idx < 0 {
91+ return "", false
92+ }
93+ if idx > 0 && input[idx-1] != ' ' {
94+ return "", false
95+ }
96+ query := input[idx+1:]
97+ if strings.ContainsAny(query, " \t\n") {
98+ return "", false
99+ }
100+ return query, true
101+}
102+
103+// matches returns file entries matching the current @-query.
104+func (s *fileSuggester) matches(input string) []fileEntry {
105+ query, ok := extractAtQuery(input)
106+ if !ok {
107+ return nil
108+ }
109+ all := s.scan()
110+ if len(all) == 0 {
111+ return nil
112+ }
113+ needle := strings.ToLower(query)
114+ if needle == "" {
115+ return all
116+ }
117+ var out []fileEntry
118+ for _, e := range all {
119+ if strings.Contains(strings.ToLower(e.name), needle) {
120+ out = append(out, e)
121+ }
122+ }
123+ return out
124+}
125+
126+// Active reports whether the popup should be visible.
127+func (s *fileSuggester) Active(input string) bool {
128+ return len(s.matches(input)) > 0
129+}
130+
131+// Reset clears state.
132+func (s *fileSuggester) Reset() {
133+ s.cursor = 0
134+ s.lastMatches = nil
135+ s.browseRel = ""
136+ s.cachedDir = ""
137+ s.cachedAll = nil
138+}
139+
140+// Invalidate forces a rescan.
141+func (s *fileSuggester) Invalidate() {
142+ s.cachedDir = ""
143+ s.cachedAll = nil
144+}
145+
146+func (s *fileSuggester) Up() {
147+ if s.cursor > 0 {
148+ s.cursor--
149+ }
150+}
151+
152+func (s *fileSuggester) Down() {
153+ if s.cursor < len(s.lastMatches)-1 {
154+ s.cursor++
155+ }
156+}
157+
158+// Right opens the selected directory, descending into it.
159+// Returns true if a directory was entered.
160+func (s *fileSuggester) Right() bool {
161+ m := s.lastMatches
162+ if len(m) == 0 || s.cursor >= len(m) {
163+ return false
164+ }
165+ e := m[s.cursor]
166+ if !e.isDir {
167+ return false
168+ }
169+ s.browseRel = e.rel
170+ s.cachedDir = ""
171+ s.cachedAll = nil
172+ s.cursor = 0
173+ return true
174+}
175+
176+// Left goes back to the parent directory.
177+// Returns true if we moved up.
178+func (s *fileSuggester) Left() bool {
179+ if s.browseRel == "" {
180+ return false
181+ }
182+ parent := filepath.Dir(s.browseRel)
183+ if parent == "." {
184+ parent = ""
185+ }
186+ s.browseRel = parent
187+ s.cachedDir = ""
188+ s.cachedAll = nil
189+ s.cursor = 0
190+ return true
191+}
192+
193+// Selection returns the relative path of the currently highlighted entry.
194+func (s *fileSuggester) Selection(input string) string {
195+ m := s.matches(input)
196+ if len(m) == 0 {
197+ return ""
198+ }
199+ if s.cursor >= len(m) {
200+ s.cursor = len(m) - 1
201+ }
202+ return m[s.cursor].rel
203+}
204+
205+// SelectedEntry returns the currently highlighted entry.
206+func (s *fileSuggester) SelectedEntry(input string) (fileEntry, bool) {
207+ m := s.matches(input)
208+ if len(m) == 0 {
209+ return fileEntry{}, false
210+ }
211+ if s.cursor >= len(m) {
212+ s.cursor = len(m) - 1
213+ }
214+ return m[s.cursor], true
215+}
216+
217+// Render returns the popup lines.
218+func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string {
219+ m := s.matches(input)
220+ if len(m) == 0 {
221+ return nil
222+ }
223+ s.lastMatches = m
224+ if s.cursor >= len(m) {
225+ s.cursor = len(m) - 1
226+ }
227+
228+ const maxVisible = 12
229+ visible := m
230+ offset := 0
231+ if len(m) > maxVisible {
232+ if s.cursor >= maxVisible {
233+ offset = s.cursor - maxVisible + 1
234+ }
235+ end := offset + maxVisible
236+ if end > len(m) {
237+ end = len(m)
238+ offset = end - maxVisible
239+ }
240+ visible = m[offset:end]
241+ }
242+
243+ var out []string
244+
245+ // Show breadcrumb when browsing a subdirectory.
246+ if s.browseRel != "" {
247+ crumb := th.FG256(th.Accent, " "+s.browseRel+"/")
248+ out = append(out, crumb)
249+ }
250+
251+ for i, e := range visible {
252+ idx := offset + i
253+ name := e.name
254+ if e.isDir {
255+ name += "/"
256+ }
257+ plain := " " + name
258+ if idx == s.cursor {
259+ out = append(out, th.PadHighlight(plain, width))
260+ } else {
261+ out = append(out, th.FG256(th.Muted, plain))
262+ }
263+ }
264+
265+ out = append(out, "")
266+ hint := " \u2191/\u2193 navigate - enter select - esc cancel"
267+ if s.browseRel != "" {
268+ hint = " \u2191/\u2193 navigate - \u2192 open - \u2190 back - enter select - esc cancel"
269+ } else {
270+ hint = " \u2191/\u2193 navigate - \u2192 open dir - enter select - esc cancel"
271+ }
272+ out = append(out, th.FG256(th.Muted, hint))
273+ out = append(out, "")
274+ return out
275+}
276+
277+// fileChipRE matches [file:N:name] and [dir:N:name/] chips.
278+var fileChipRE = regexp.MustCompile(`\[(file|dir):(\d+):[^\]]+\]`)
279+
280+// expandFileChips replaces [file:name] and [dir:name/] chips with
281+// the full path relative to cwd.
282+func expandFileChips(text, cwd string) string {
283+ return fileChipRE.ReplaceAllStringFunc(text, func(match string) string {
284+ groups := fileChipRE.FindStringSubmatch(match)
285+ if len(groups) < 3 {
286+ return match
287+ }
288+ name := groups[2]
289+ // Strip trailing slash from dir names.
290+ name = strings.TrimRight(name, "/")
291+ return filepath.Join(cwd, name)
292+ })
293+}
M internal/agent/modes/interactive.go +54 -3
@@ -226,6 +226,7 @@ type Interactive struct {
226226 // is dismissed, so repeated /jump calls don't turn into forks.
227227 pendingFork bool
228228 suggest *slashSuggester
229+ fileSuggest *fileSuggester
229230 spin *spinner
230231
231232 // parkedTurn is the 1-based turn number the viewport is currently
@@ -282,6 +283,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
282283 sessionTreeDialog: newSessionTreeDialog(),
283284 extPanel: newExtPanelDialog(),
284285 suggest: newSlashSuggester(),
286+ fileSuggest: newFileSuggester(),
285287 spin: newSpinner(),
286288 }
287289 if cfg.Agent != nil {
@@ -736,8 +738,11 @@ func (i *Interactive) redraw() {
736738 // without waiting for the current turn to finish. The dispatcher
737739 // in runSlash already handles the busy case per-command: safe
738740 // ones run immediately, destructive ones cancel the turn first.
741+ i.fileSuggest.SetCWD(i.cfg.CWD)
739742 if len(dialog) == 0 && i.suggest.Active(currentInput) {
740743 suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
744+ } else if len(dialog) == 0 && i.fileSuggest.Active(currentInput) {
745+ suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols)
741746 }
742747
743748 // Busy prefix shown at the far left of the status bar. The
@@ -1297,7 +1302,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
12971302 // notes should dismiss on Esc before we even consider the
12981303 // turn. Without these guards, a casual Esc press after
12991304 // running /help on a busy turn rips the turn away.
1300- if i.suggest.Active(i.ed.Value()) {
1305+ if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {
13011306 break
13021307 }
13031308 i.mu.Lock()
@@ -1350,12 +1355,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
13501355 // a draft present at the cost of disabling vertical cursor
13511356 // motion inside the multi-line editor. Keep slash-popup
13521357 // navigation working by letting it intercept later when active.
1353- if !i.suggest.Active(i.ed.Value()) {
1358+ if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
13541359 i.scrollBy(+3)
13551360 return false
13561361 }
13571362 case tui.KeyDown:
1358- if !i.suggest.Active(i.ed.Value()) {
1363+ if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
13591364 if i.scrollOffset > 0 {
13601365 i.scrollBy(-3)
13611366 }
@@ -1407,16 +1412,62 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
14071412 }
14081413 }
14091414
1415+ // File suggestions: intercept up/down/tab/enter when the @-popup is visible.
1416+ if i.fileSuggest.Active(i.ed.Value()) {
1417+ switch k.Kind {
1418+ case tui.KeyUp:
1419+ i.fileSuggest.Up()
1420+ return false
1421+ case tui.KeyDown:
1422+ i.fileSuggest.Down()
1423+ return false
1424+ case tui.KeyRight:
1425+ // Open selected directory.
1426+ i.fileSuggest.Right()
1427+ return false
1428+ case tui.KeyLeft:
1429+ // Go back to parent directory.
1430+ i.fileSuggest.Left()
1431+ return false
1432+ case tui.KeyEnter:
1433+ if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
1434+ var chip string
1435+ if entry.isDir {
1436+ chip = "[dir:" + entry.rel + "/]"
1437+ } else {
1438+ chip = "[file:" + entry.rel + "]"
1439+ }
1440+ val := i.ed.Value()
1441+ if idx := strings.LastIndex(val, "@"); idx >= 0 {
1442+ val = val[:idx]
1443+ }
1444+ i.ed.SetValue(val + chip + " ")
1445+ i.fileSuggest.Reset()
1446+ }
1447+ return false
1448+ case tui.KeyEsc:
1449+ val := i.ed.Value()
1450+ if idx := strings.LastIndex(val, "@"); idx >= 0 {
1451+ i.ed.SetValue(val[:idx])
1452+ }
1453+ i.fileSuggest.Reset()
1454+ return false
1455+ }
1456+ }
1457+
14101458 if submit := i.ed.HandleKey(k); submit {
14111459 // SubmitValue() expands any [pasted text #N +L lines]
14121460 // placeholders back into their bodies; the raw Value()
14131461 // is only what the user sees on screen.
14141462 text := strings.TrimRight(i.ed.SubmitValue(), "\n")
1463+ // Expand [file:name] and [dir:name/] chips to full paths.
1464+ text = expandFileChips(text, i.cfg.CWD)
14151465 if text == "" {
14161466 return false
14171467 }
14181468 i.ed.Clear()
14191469 i.suggest.Reset()
1470+ i.fileSuggest.Reset()
14201471
14211472 if looksLikeSlashCommand(text) {
14221473 head := text
@@ -226,6 +226,7 @@ type Interactive struct {
226 // is dismissed, so repeated /jump calls don't turn into forks.226 // is dismissed, so repeated /jump calls don't turn into forks.
227 pendingFork bool227 pendingFork bool
228 suggest *slashSuggester228 suggest *slashSuggester
229+ fileSuggest *fileSuggester
229 spin *spinner230 spin *spinner
230 231
231 // parkedTurn is the 1-based turn number the viewport is currently232 // parkedTurn is the 1-based turn number the viewport is currently
@@ -282,6 +283,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
282 sessionTreeDialog: newSessionTreeDialog(),283 sessionTreeDialog: newSessionTreeDialog(),
283 extPanel: newExtPanelDialog(),284 extPanel: newExtPanelDialog(),
284 suggest: newSlashSuggester(),285 suggest: newSlashSuggester(),
286+ fileSuggest: newFileSuggester(),
285 spin: newSpinner(),287 spin: newSpinner(),
286 }288 }
287 if cfg.Agent != nil {289 if cfg.Agent != nil {
@@ -736,8 +738,11 @@ func (i *Interactive) redraw() {
736 // without waiting for the current turn to finish. The dispatcher738 // without waiting for the current turn to finish. The dispatcher
737 // in runSlash already handles the busy case per-command: safe739 // in runSlash already handles the busy case per-command: safe
738 // ones run immediately, destructive ones cancel the turn first.740 // ones run immediately, destructive ones cancel the turn first.
741+ i.fileSuggest.SetCWD(i.cfg.CWD)
739 if len(dialog) == 0 && i.suggest.Active(currentInput) {742 if len(dialog) == 0 && i.suggest.Active(currentInput) {
740 suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)743 suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
744+ } else if len(dialog) == 0 && i.fileSuggest.Active(currentInput) {
745+ suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols)
741 }746 }
742 747
743 // Busy prefix shown at the far left of the status bar. The748 // Busy prefix shown at the far left of the status bar. The
@@ -1297,7 +1302,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1297 // notes should dismiss on Esc before we even consider the1302 // notes should dismiss on Esc before we even consider the
1298 // turn. Without these guards, a casual Esc press after1303 // turn. Without these guards, a casual Esc press after
1299 // running /help on a busy turn rips the turn away.1304 // running /help on a busy turn rips the turn away.
1300- if i.suggest.Active(i.ed.Value()) {1305+ if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {
1301 break1306 break
1302 }1307 }
1303 i.mu.Lock()1308 i.mu.Lock()
@@ -1350,12 +1355,12 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1350 // a draft present at the cost of disabling vertical cursor1355 // a draft present at the cost of disabling vertical cursor
1351 // motion inside the multi-line editor. Keep slash-popup1356 // motion inside the multi-line editor. Keep slash-popup
1352 // navigation working by letting it intercept later when active.1357 // navigation working by letting it intercept later when active.
1353- if !i.suggest.Active(i.ed.Value()) {1358+ if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
1354 i.scrollBy(+3)1359 i.scrollBy(+3)
1355 return false1360 return false
1356 }1361 }
1357 case tui.KeyDown:1362 case tui.KeyDown:
1358- if !i.suggest.Active(i.ed.Value()) {1363+ if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
1359 if i.scrollOffset > 0 {1364 if i.scrollOffset > 0 {
1360 i.scrollBy(-3)1365 i.scrollBy(-3)
1361 }1366 }
@@ -1407,16 +1412,62 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1407 }1412 }
1408 }1413 }
1409 1414
1415+ // File suggestions: intercept up/down/tab/enter when the @-popup is visible.
1416+ if i.fileSuggest.Active(i.ed.Value()) {
1417+ switch k.Kind {
1418+ case tui.KeyUp:
1419+ i.fileSuggest.Up()
1420+ return false
1421+ case tui.KeyDown:
1422+ i.fileSuggest.Down()
1423+ return false
1424+ case tui.KeyRight:
1425+ // Open selected directory.
1426+ i.fileSuggest.Right()
1427+ return false
1428+ case tui.KeyLeft:
1429+ // Go back to parent directory.
1430+ i.fileSuggest.Left()
1431+ return false
1432+ case tui.KeyEnter:
1433+ if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
1434+ var chip string
1435+ if entry.isDir {
1436+ chip = "[dir:" + entry.rel + "/]"
1437+ } else {
1438+ chip = "[file:" + entry.rel + "]"
1439+ }
1440+ val := i.ed.Value()
1441+ if idx := strings.LastIndex(val, "@"); idx >= 0 {
1442+ val = val[:idx]
1443+ }
1444+ i.ed.SetValue(val + chip + " ")
1445+ i.fileSuggest.Reset()
1446+ }
1447+ return false
1448+ case tui.KeyEsc:
1449+ val := i.ed.Value()
1450+ if idx := strings.LastIndex(val, "@"); idx >= 0 {
1451+ i.ed.SetValue(val[:idx])
1452+ }
1453+ i.fileSuggest.Reset()
1454+ return false
1455+ }
1456+ }
1457+
1410 if submit := i.ed.HandleKey(k); submit {1458 if submit := i.ed.HandleKey(k); submit {
1411 // SubmitValue() expands any [pasted text #N +L lines]1459 // SubmitValue() expands any [pasted text #N +L lines]
1412 // placeholders back into their bodies; the raw Value()1460 // placeholders back into their bodies; the raw Value()
1413 // is only what the user sees on screen.1461 // is only what the user sees on screen.
1414 text := strings.TrimRight(i.ed.SubmitValue(), "\n")1462 text := strings.TrimRight(i.ed.SubmitValue(), "\n")
1463+ // Expand [file:name] and [dir:name/] chips to full paths.
1464+ text = expandFileChips(text, i.cfg.CWD)
1415 if text == "" {1465 if text == "" {
1416 return false1466 return false
1417 }1467 }
1418 i.ed.Clear()1468 i.ed.Clear()
1419 i.suggest.Reset()1469 i.suggest.Reset()
1470+ i.fileSuggest.Reset()
1420 1471
1421 if looksLikeSlashCommand(text) {1472 if looksLikeSlashCommand(text) {
1422 head := text1473 head := text
M internal/tui/editor.go +37 -15
@@ -3,6 +3,7 @@ package tui
33 import (
44 "fmt"
55 "net/url"
6+ "os"
67 "path/filepath"
78 "regexp"
89 "strings"
@@ -41,11 +42,14 @@ type Editor struct {
4142 pastes map[int]string
4243 pasteSeq int
4344
44- // files stores full paths of drag-dropped file paths, keyed by
45- // a sequence id. The editor shows a compact [file:basename] chip
46- // and SubmitValue() expands it back to the full path.
45+ // files and dirs store full paths of drag-dropped items, keyed
46+ // by separate sequence ids. The editor shows compact chips like
47+ // [file:1:name] and [dir:1:name/] and SubmitValue() expands
48+ // them back to the full path.
4749 files map[int]string
4850 fileSeq int
51+ dirs map[int]string
52+ dirSeq int
4953 }
5054
5155 // NewEditor returns an empty editor with the given prompt.
@@ -78,8 +82,8 @@ func (e *Editor) SubmitValue() string {
7882 if len(e.pastes) > 0 && strings.Contains(raw, "[pasted text #") {
7983 raw = expandPastePlaceholders(raw, e.pastes)
8084 }
81- if len(e.files) > 0 && strings.Contains(raw, "[file:") {
82- raw = expandFilePlaceholders(raw, e.files)
85+ if (len(e.files) > 0 || len(e.dirs) > 0) && (strings.Contains(raw, "[file:") || strings.Contains(raw, "[dir:")) {
86+ raw = expandFilePlaceholders(raw, e.files, e.dirs)
8387 }
8488 return raw
8589 }
@@ -98,6 +102,8 @@ func (e *Editor) SetValue(s string) {
98102 e.pasteSeq = 0
99103 e.files = nil
100104 e.fileSeq = 0
105+ e.dirs = nil
106+ e.dirSeq = 0
101107 }
102108
103109 // Clear resets the buffer.
@@ -1146,7 +1152,7 @@ func countLines(s string) int {
11461152 // body in e.pastes; the rest of the token is free-form and gets
11471153 // discarded during expansion.
11481154 var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) (?:\+\d+ lines?|\d+ chars?)\]`)
1149-var filePlaceholderRE = regexp.MustCompile(`\[file:(\d+):[^\]]+\]`)
1155+var filePlaceholderRE = regexp.MustCompile(`\[(file|dir):(\d+):[^\]]+\]`)
11501156
11511157 // expandPastePlaceholders returns raw with every paste token
11521158 // swapped for the body stored under its id in pastes. Tokens
@@ -1170,19 +1176,26 @@ func expandPastePlaceholders(raw string, pastes map[int]string) string {
11701176 })
11711177 }
11721178
1173-// expandFilePlaceholders returns raw with every [file:N:name] token
1174-// swapped for the full path stored under its id.
1175-func expandFilePlaceholders(raw string, files map[int]string) string {
1179+// expandFilePlaceholders returns raw with every [file:N:name] and
1180+// [dir:N:name/] token swapped for the full path stored under its id.
1181+func expandFilePlaceholders(raw string, files, dirs map[int]string) string {
11761182 return filePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {
11771183 groups := filePlaceholderRE.FindStringSubmatch(match)
1178- if len(groups) < 2 {
1184+ if len(groups) < 3 {
11791185 return match
11801186 }
1187+ kind := groups[1] // "file" or "dir"
11811188 var id int
1182- if _, err := fmt.Sscanf(groups[1], "%d", &id); err != nil {
1189+ if _, err := fmt.Sscanf(groups[2], "%d", &id); err != nil {
11831190 return match
11841191 }
1185- if path, ok := files[id]; ok {
1192+ var m map[int]string
1193+ if kind == "dir" {
1194+ m = dirs
1195+ } else {
1196+ m = files
1197+ }
1198+ if path, ok := m[id]; ok {
11861199 return path
11871200 }
11881201 return match
@@ -1212,11 +1225,20 @@ func (e *Editor) collapseOrQuoteFilePaths(paste string) string {
12121225 // Short path like "./foo.txt" - just quote it.
12131226 return singleQuote(p)
12141227 }
1228+ // Check if it's a directory.
1229+ info, err := os.Stat(p)
1230+ if err == nil && info.IsDir() {
1231+ if e.dirs == nil {
1232+ e.dirs = map[int]string{}
1233+ }
1234+ e.dirSeq++
1235+ e.dirs[e.dirSeq] = singleQuote(p)
1236+ return fmt.Sprintf("[dir:%d:%s/]", e.dirSeq, base)
1237+ }
12151238 if e.files == nil {
12161239 e.files = map[int]string{}
12171240 }
12181241 e.fileSeq++
1219- id := e.fileSeq
1220- e.files[id] = singleQuote(p)
1221- return fmt.Sprintf("[file:%d:%s]", id, base)
1242+ e.files[e.fileSeq] = singleQuote(p)
1243+ return fmt.Sprintf("[file:%d:%s]", e.fileSeq, base)
12221244 }
@@ -3,6 +3,7 @@ package tui
3 import (3 import (
4 "fmt"4 "fmt"
5 "net/url"5 "net/url"
6+ "os"
6 "path/filepath"7 "path/filepath"
7 "regexp"8 "regexp"
8 "strings"9 "strings"
@@ -41,11 +42,14 @@ type Editor struct {
41 pastes map[int]string42 pastes map[int]string
42 pasteSeq int43 pasteSeq int
43 44
44- // files stores full paths of drag-dropped file paths, keyed by45+ // files and dirs store full paths of drag-dropped items, keyed
45- // a sequence id. The editor shows a compact [file:basename] chip46+ // by separate sequence ids. The editor shows compact chips like
46- // and SubmitValue() expands it back to the full path.47+ // [file:1:name] and [dir:1:name/] and SubmitValue() expands
48+ // them back to the full path.
47 files map[int]string49 files map[int]string
48 fileSeq int50 fileSeq int
51+ dirs map[int]string
52+ dirSeq int
49 }53 }
50 54
51 // NewEditor returns an empty editor with the given prompt.55 // NewEditor returns an empty editor with the given prompt.
@@ -78,8 +82,8 @@ func (e *Editor) SubmitValue() string {
78 if len(e.pastes) > 0 && strings.Contains(raw, "[pasted text #") {82 if len(e.pastes) > 0 && strings.Contains(raw, "[pasted text #") {
79 raw = expandPastePlaceholders(raw, e.pastes)83 raw = expandPastePlaceholders(raw, e.pastes)
80 }84 }
81- if len(e.files) > 0 && strings.Contains(raw, "[file:") {85+ if (len(e.files) > 0 || len(e.dirs) > 0) && (strings.Contains(raw, "[file:") || strings.Contains(raw, "[dir:")) {
82- raw = expandFilePlaceholders(raw, e.files)86+ raw = expandFilePlaceholders(raw, e.files, e.dirs)
83 }87 }
84 return raw88 return raw
85 }89 }
@@ -98,6 +102,8 @@ func (e *Editor) SetValue(s string) {
98 e.pasteSeq = 0102 e.pasteSeq = 0
99 e.files = nil103 e.files = nil
100 e.fileSeq = 0104 e.fileSeq = 0
105+ e.dirs = nil
106+ e.dirSeq = 0
101 }107 }
102 108
103 // Clear resets the buffer.109 // Clear resets the buffer.
@@ -1146,7 +1152,7 @@ func countLines(s string) int {
1146 // body in e.pastes; the rest of the token is free-form and gets1152 // body in e.pastes; the rest of the token is free-form and gets
1147 // discarded during expansion.1153 // discarded during expansion.
1148 var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) (?:\+\d+ lines?|\d+ chars?)\]`)1154 var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) (?:\+\d+ lines?|\d+ chars?)\]`)
1149-var filePlaceholderRE = regexp.MustCompile(`\[file:(\d+):[^\]]+\]`)1155+var filePlaceholderRE = regexp.MustCompile(`\[(file|dir):(\d+):[^\]]+\]`)
1150 1156
1151 // expandPastePlaceholders returns raw with every paste token1157 // expandPastePlaceholders returns raw with every paste token
1152 // swapped for the body stored under its id in pastes. Tokens1158 // swapped for the body stored under its id in pastes. Tokens
@@ -1170,19 +1176,26 @@ func expandPastePlaceholders(raw string, pastes map[int]string) string {
1170 })1176 })
1171 }1177 }
1172 1178
1173-// expandFilePlaceholders returns raw with every [file:N:name] token1179+// expandFilePlaceholders returns raw with every [file:N:name] and
1174-// swapped for the full path stored under its id.1180+// [dir:N:name/] token swapped for the full path stored under its id.
1175-func expandFilePlaceholders(raw string, files map[int]string) string {1181+func expandFilePlaceholders(raw string, files, dirs map[int]string) string {
1176 return filePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {1182 return filePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {
1177 groups := filePlaceholderRE.FindStringSubmatch(match)1183 groups := filePlaceholderRE.FindStringSubmatch(match)
1178- if len(groups) < 2 {1184+ if len(groups) < 3 {
1179 return match1185 return match
1180 }1186 }
1187+ kind := groups[1] // "file" or "dir"
1181 var id int1188 var id int
1182- if _, err := fmt.Sscanf(groups[1], "%d", &id); err != nil {1189+ if _, err := fmt.Sscanf(groups[2], "%d", &id); err != nil {
1183 return match1190 return match
1184 }1191 }
1185- if path, ok := files[id]; ok {1192+ var m map[int]string
1193+ if kind == "dir" {
1194+ m = dirs
1195+ } else {
1196+ m = files
1197+ }
1198+ if path, ok := m[id]; ok {
1186 return path1199 return path
1187 }1200 }
1188 return match1201 return match
@@ -1212,11 +1225,20 @@ func (e *Editor) collapseOrQuoteFilePaths(paste string) string {
1212 // Short path like "./foo.txt" - just quote it.1225 // Short path like "./foo.txt" - just quote it.
1213 return singleQuote(p)1226 return singleQuote(p)
1214 }1227 }
1228+ // Check if it's a directory.
1229+ info, err := os.Stat(p)
1230+ if err == nil && info.IsDir() {
1231+ if e.dirs == nil {
1232+ e.dirs = map[int]string{}
1233+ }
1234+ e.dirSeq++
1235+ e.dirs[e.dirSeq] = singleQuote(p)
1236+ return fmt.Sprintf("[dir:%d:%s/]", e.dirSeq, base)
1237+ }
1215 if e.files == nil {1238 if e.files == nil {
1216 e.files = map[int]string{}1239 e.files = map[int]string{}
1217 }1240 }
1218 e.fileSeq++1241 e.fileSeq++
1219- id := e.fileSeq1242+ e.files[e.fileSeq] = singleQuote(p)
1220- e.files[id] = singleQuote(p)1243+ return fmt.Sprintf("[file:%d:%s]", e.fileSeq, base)
1221- return fmt.Sprintf("[file:%d:%s]", id, base)
1222 }1244 }

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