detached ad93054

feat(editor): extend path tab-completion to /btw and /swarm editors

The shell-style path tab-completion added in the previous commit only
ran against the main interactive editor; /btw and /swarm have their
own *tui.Editor instances inside their dialogs and routed Tab through
editor.HandleKey untouched, so '~/Dev'+Tab inserted nothing in those
modes.

Refactored the completion into a free helper:

  tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool

The Interactive method is now a thin wrapper that adds frame
invalidation. btwDialog gained a cwd field (set by Open) and calls
the helper before forwarding to editor.HandleKey. swarmDialog
already tracked d.cwd; both its prompt-editor and spawn-editor
paths now run the helper before HandleKey, gated to skip when the
@-picker popup is active so Tab there still selects the highlighted
chip.

Net effect: ~/Dev+Tab, ./int+Tab, cmd/+Tab, etc. now work the same
way in the main composer, in /btw side chats, and in /swarm spawn
and follow-up prompts.
commit
ad93054ac46e7c329d06afb01f74f6eae3a7f36d
author
patriceckhart · · 2026-05-19 16:50 UTC
parent
8096aeb
signature
unsigned
refs
tag: v0.1.84
3 files · +67 -15
M internal/agent/modes/btw_dialog.go +18 -1
@@ -57,6 +57,13 @@ type btwDialog struct {
5757
5858 // Theme cached so render() doesn't need it threaded through.
5959 theme tui.Theme
60+
61+ // cwd is the working directory used to resolve relative paths
62+ // when the user presses Tab on a path-like token in the side-
63+ // chat editor. Set by Open() from the host's cwd so the same
64+ // path-completion that works in the main editor also works
65+ // here.
66+ cwd string
6067 }
6168
6269 func newBtwDialog() *btwDialog {
@@ -93,7 +100,7 @@ func (d *btwDialog) Loading() bool {
93100 // auto-submitted (so /btw <text> starts a conversation right away).
94101 // invalidate, if non-nil, is called after each state change so the
95102 // host redraw loop can pick up the update without polling.
96-func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed string, invalidate func()) {
103+func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, invalidate func()) {
97104 d.mu.Lock()
98105 d.active = true
99106 d.theme = th
@@ -105,6 +112,7 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed st
105112 d.frozenMsgs = agent.Messages()
106113 d.client = agent.Client
107114 d.model = model
115+ d.cwd = cwd
108116 d.mu.Unlock()
109117
110118 if seed = strings.TrimSpace(seed); seed != "" {
@@ -157,10 +165,19 @@ func (d *btwDialog) HandleKey(k tui.Key, invalidate func()) (closed bool) {
157165 d.mu.Lock()
158166 editor := d.editor
159167 loading := d.loading
168+ cwd := d.cwd
160169 d.mu.Unlock()
161170 if editor == nil {
162171 return false
163172 }
173+ // Tab-complete a path-like token before the editor sees the key,
174+ // matching the main editor's behaviour.
175+ if k.Kind == tui.KeyTab {
176+ if tryPathTabCompleteEditor(editor, cwd) {
177+ invalidate()
178+ return false
179+ }
180+ }
164181 // Don't accept new submissions while a call is in flight; arrow
165182 // keys / scrolling still flow through to the editor for caret
166183 // movement and history.
@@ -57,6 +57,13 @@ type btwDialog struct {
57 57
58 // Theme cached so render() doesn't need it threaded through.58 // Theme cached so render() doesn't need it threaded through.
59 theme tui.Theme59 theme tui.Theme
60+
61+ // cwd is the working directory used to resolve relative paths
62+ // when the user presses Tab on a path-like token in the side-
63+ // chat editor. Set by Open() from the host's cwd so the same
64+ // path-completion that works in the main editor also works
65+ // here.
66+ cwd string
60 }67 }
61 68
62 func newBtwDialog() *btwDialog {69 func newBtwDialog() *btwDialog {
@@ -93,7 +100,7 @@ func (d *btwDialog) Loading() bool {
93 // auto-submitted (so /btw <text> starts a conversation right away).100 // auto-submitted (so /btw <text> starts a conversation right away).
94 // invalidate, if non-nil, is called after each state change so the101 // invalidate, if non-nil, is called after each state change so the
95 // host redraw loop can pick up the update without polling.102 // host redraw loop can pick up the update without polling.
96-func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed string, invalidate func()) {103+func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, invalidate func()) {
97 d.mu.Lock()104 d.mu.Lock()
98 d.active = true105 d.active = true
99 d.theme = th106 d.theme = th
@@ -105,6 +112,7 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed st
105 d.frozenMsgs = agent.Messages()112 d.frozenMsgs = agent.Messages()
106 d.client = agent.Client113 d.client = agent.Client
107 d.model = model114 d.model = model
115+ d.cwd = cwd
108 d.mu.Unlock()116 d.mu.Unlock()
109 117
110 if seed = strings.TrimSpace(seed); seed != "" {118 if seed = strings.TrimSpace(seed); seed != "" {
@@ -157,10 +165,19 @@ func (d *btwDialog) HandleKey(k tui.Key, invalidate func()) (closed bool) {
157 d.mu.Lock()165 d.mu.Lock()
158 editor := d.editor166 editor := d.editor
159 loading := d.loading167 loading := d.loading
168+ cwd := d.cwd
160 d.mu.Unlock()169 d.mu.Unlock()
161 if editor == nil {170 if editor == nil {
162 return false171 return false
163 }172 }
173+ // Tab-complete a path-like token before the editor sees the key,
174+ // matching the main editor's behaviour.
175+ if k.Kind == tui.KeyTab {
176+ if tryPathTabCompleteEditor(editor, cwd) {
177+ invalidate()
178+ return false
179+ }
180+ }
164 // Don't accept new submissions while a call is in flight; arrow181 // Don't accept new submissions while a call is in flight; arrow
165 // keys / scrolling still flow through to the editor for caret182 // keys / scrolling still flow through to the editor for caret
166 // movement and history.183 // movement and history.
M internal/agent/modes/interactive.go +31 -14
@@ -2412,27 +2412,34 @@ func buildStudyPrompt(arg, cwd string) string {
24122412 return "Read and understand everything in the directory " + display + "."
24132413 }
24142414
2415-// tryPathTabComplete looks at the editor's current value, finds the
2416-// path-like token immediately before the cursor (in this codebase the
2417-// cursor is always at the end of the buffer after a keystroke, so
2418-// "before the cursor" is the trailing non-whitespace run), and rewrites
2419-// it to its shell-style completion against the filesystem.
2415+// tryPathTabCompleteEditor looks at ed's current value, finds the
2416+// path-like token immediately before the cursor (the cursor is always
2417+// at the end of the buffer after a keystroke, so "before the cursor"
2418+// is the trailing non-whitespace run), and rewrites it to its shell-
2419+// style completion against the filesystem.
24202420 //
24212421 // Returns true when it consumed the Tab keystroke (token recognised,
24222422 // completion attempted — even if no candidates matched, the keystroke
24232423 // is still consumed so it doesn't insert a literal tab character).
2424-// Returns false when the token doesn't look like a path; the caller
2425-// then lets Tab fall through to its normal no-op.
2424+// Returns false when the token doesn't look like a path; callers then
2425+// let Tab fall through to its normal no-op.
24262426 //
24272427 // Recognised path shapes:
24282428 // - ~ or ~/foo expanded via os.UserHomeDir()
24292429 // - /abs/path or /abs/path/foo absolute
2430-// - ./foo, ../foo, foo/bar relative to i.cfg.CWD
2430+// - ./foo, ../foo, foo/bar relative to cwd
24312431 //
24322432 // A bare word like "hello" is not treated as a path so plain text
24332433 // keeps Tab as a literal no-op.
2434-func (i *Interactive) tryPathTabComplete() bool {
2435- val := i.ed.Value()
2434+//
2435+// Free function (not a method) so the same logic runs against the
2436+// editor instances owned by btwDialog and swarmDialog without each
2437+// dialog needing its own copy.
2438+func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
2439+ if ed == nil {
2440+ return false
2441+ }
2442+ val := ed.Value()
24362443 // Find the trailing run of non-whitespace.
24372444 start := len(val)
24382445 for start > 0 {
@@ -2451,7 +2458,7 @@ func (i *Interactive) tryPathTabComplete() bool {
24512458 }
24522459
24532460 // Resolve the absolute parent directory + base prefix to match.
2454- parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, i.cfg.CWD)
2461+ parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
24552462 if !ok {
24562463 return true
24572464 }
@@ -2498,11 +2505,21 @@ func (i *Interactive) tryPathTabComplete() bool {
24982505 newToken += "/"
24992506 }
25002507
2501- i.ed.SetValue(val[:start] + newToken)
2502- i.invalidate()
2508+ ed.SetValue(val[:start] + newToken)
25032509 return true
25042510 }
25052511
2512+// tryPathTabComplete is the Interactive-bound convenience wrapper.
2513+// It calls the free helper against the main editor and invalidates
2514+// the frame on a successful rewrite.
2515+func (i *Interactive) tryPathTabComplete() bool {
2516+ if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {
2517+ i.invalidate()
2518+ return true
2519+ }
2520+ return false
2521+}
2522+
25062523 // looksLikePathToken reports whether tok is shaped like a filesystem
25072524 // path. Paths must either start with ~, /, ./, ../ or contain a /.
25082525 // Plain words are excluded so Tab on "hello" stays a no-op.
@@ -2990,7 +3007,7 @@ func (i *Interactive) openBtwDialog(args []string) {
29903007 return
29913008 }
29923009 seed := strings.TrimSpace(strings.Join(args, " "))
2993- i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed, i.invalidate)
3010+ i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.invalidate)
29943011 i.invalidate()
29953012 }
29963013
@@ -2412,27 +2412,34 @@ func buildStudyPrompt(arg, cwd string) string {
2412 return "Read and understand everything in the directory " + display + "."2412 return "Read and understand everything in the directory " + display + "."
2413 }2413 }
2414 2414
2415-// tryPathTabComplete looks at the editor's current value, finds the2415+// tryPathTabCompleteEditor looks at ed's current value, finds the
2416-// path-like token immediately before the cursor (in this codebase the2416+// path-like token immediately before the cursor (the cursor is always
2417-// cursor is always at the end of the buffer after a keystroke, so2417+// at the end of the buffer after a keystroke, so "before the cursor"
2418-// "before the cursor" is the trailing non-whitespace run), and rewrites2418+// is the trailing non-whitespace run), and rewrites it to its shell-
2419-// it to its shell-style completion against the filesystem.2419+// style completion against the filesystem.
2420 //2420 //
2421 // Returns true when it consumed the Tab keystroke (token recognised,2421 // Returns true when it consumed the Tab keystroke (token recognised,
2422 // completion attempted — even if no candidates matched, the keystroke2422 // completion attempted — even if no candidates matched, the keystroke
2423 // is still consumed so it doesn't insert a literal tab character).2423 // is still consumed so it doesn't insert a literal tab character).
2424-// Returns false when the token doesn't look like a path; the caller2424+// Returns false when the token doesn't look like a path; callers then
2425-// then lets Tab fall through to its normal no-op.2425+// let Tab fall through to its normal no-op.
2426 //2426 //
2427 // Recognised path shapes:2427 // Recognised path shapes:
2428 // - ~ or ~/foo expanded via os.UserHomeDir()2428 // - ~ or ~/foo expanded via os.UserHomeDir()
2429 // - /abs/path or /abs/path/foo absolute2429 // - /abs/path or /abs/path/foo absolute
2430-// - ./foo, ../foo, foo/bar relative to i.cfg.CWD2430+// - ./foo, ../foo, foo/bar relative to cwd
2431 //2431 //
2432 // A bare word like "hello" is not treated as a path so plain text2432 // A bare word like "hello" is not treated as a path so plain text
2433 // keeps Tab as a literal no-op.2433 // keeps Tab as a literal no-op.
2434-func (i *Interactive) tryPathTabComplete() bool {2434+//
2435- val := i.ed.Value()2435+// Free function (not a method) so the same logic runs against the
2436+// editor instances owned by btwDialog and swarmDialog without each
2437+// dialog needing its own copy.
2438+func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
2439+ if ed == nil {
2440+ return false
2441+ }
2442+ val := ed.Value()
2436 // Find the trailing run of non-whitespace.2443 // Find the trailing run of non-whitespace.
2437 start := len(val)2444 start := len(val)
2438 for start > 0 {2445 for start > 0 {
@@ -2451,7 +2458,7 @@ func (i *Interactive) tryPathTabComplete() bool {
2451 }2458 }
2452 2459
2453 // Resolve the absolute parent directory + base prefix to match.2460 // Resolve the absolute parent directory + base prefix to match.
2454- parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, i.cfg.CWD)2461+ parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
2455 if !ok {2462 if !ok {
2456 return true2463 return true
2457 }2464 }
@@ -2498,11 +2505,21 @@ func (i *Interactive) tryPathTabComplete() bool {
2498 newToken += "/"2505 newToken += "/"
2499 }2506 }
2500 2507
2501- i.ed.SetValue(val[:start] + newToken)2508+ ed.SetValue(val[:start] + newToken)
2502- i.invalidate()
2503 return true2509 return true
2504 }2510 }
2505 2511
2512+// tryPathTabComplete is the Interactive-bound convenience wrapper.
2513+// It calls the free helper against the main editor and invalidates
2514+// the frame on a successful rewrite.
2515+func (i *Interactive) tryPathTabComplete() bool {
2516+ if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {
2517+ i.invalidate()
2518+ return true
2519+ }
2520+ return false
2521+}
2522+
2506 // looksLikePathToken reports whether tok is shaped like a filesystem2523 // looksLikePathToken reports whether tok is shaped like a filesystem
2507 // path. Paths must either start with ~, /, ./, ../ or contain a /.2524 // path. Paths must either start with ~, /, ./, ../ or contain a /.
2508 // Plain words are excluded so Tab on "hello" stays a no-op.2525 // Plain words are excluded so Tab on "hello" stays a no-op.
@@ -2990,7 +3007,7 @@ func (i *Interactive) openBtwDialog(args []string) {
2990 return3007 return
2991 }3008 }
2992 seed := strings.TrimSpace(strings.Join(args, " "))3009 seed := strings.TrimSpace(strings.Join(args, " "))
2993- i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed, i.invalidate)3010+ i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.invalidate)
2994 i.invalidate()3011 i.invalidate()
2995 }3012 }
2996 3013
M internal/agent/modes/swarm_dialog.go +18 -0
@@ -766,6 +766,15 @@ func (d *swarmDialog) handlePromptKey(k tui.Key) (closed bool, msg, errMsg strin
766766 return false, "", ""
767767 }
768768
769+ // Tab-complete a path-like token before the editor sees the key,
770+ // matching the main editor's behaviour. Skipped above by the
771+ // @-picker branch when its popup is active.
772+ if k.Kind == tui.KeyTab {
773+ if tryPathTabCompleteEditor(ed, d.cwd) {
774+ return false, "", ""
775+ }
776+ }
777+
769778 if submitted := ed.HandleKey(k); submitted {
770779 text := strings.TrimRight(ed.SubmitValue(), "\n")
771780 text = expandFileChips(text, d.cwd)
@@ -886,6 +895,15 @@ func (d *swarmDialog) handleSpawnKey(k tui.Key) (closed bool, msg, errMsg string
886895 return false, "", ""
887896 }
888897
898+ // Tab-complete a path-like token before the editor sees the key,
899+ // matching the main editor's behaviour. Skipped above by the
900+ // @-picker branch when its popup is active.
901+ if k.Kind == tui.KeyTab {
902+ if tryPathTabCompleteEditor(ed, d.cwd) {
903+ return false, "", ""
904+ }
905+ }
906+
889907 if submitted := ed.HandleKey(k); submitted {
890908 raw := strings.TrimRight(ed.SubmitValue(), "\n")
891909
@@ -766,6 +766,15 @@ func (d *swarmDialog) handlePromptKey(k tui.Key) (closed bool, msg, errMsg strin
766 return false, "", ""766 return false, "", ""
767 }767 }
768 768
769+ // Tab-complete a path-like token before the editor sees the key,
770+ // matching the main editor's behaviour. Skipped above by the
771+ // @-picker branch when its popup is active.
772+ if k.Kind == tui.KeyTab {
773+ if tryPathTabCompleteEditor(ed, d.cwd) {
774+ return false, "", ""
775+ }
776+ }
777+
769 if submitted := ed.HandleKey(k); submitted {778 if submitted := ed.HandleKey(k); submitted {
770 text := strings.TrimRight(ed.SubmitValue(), "\n")779 text := strings.TrimRight(ed.SubmitValue(), "\n")
771 text = expandFileChips(text, d.cwd)780 text = expandFileChips(text, d.cwd)
@@ -886,6 +895,15 @@ func (d *swarmDialog) handleSpawnKey(k tui.Key) (closed bool, msg, errMsg string
886 return false, "", ""895 return false, "", ""
887 }896 }
888 897
898+ // Tab-complete a path-like token before the editor sees the key,
899+ // matching the main editor's behaviour. Skipped above by the
900+ // @-picker branch when its popup is active.
901+ if k.Kind == tui.KeyTab {
902+ if tryPathTabCompleteEditor(ed, d.cwd) {
903+ return false, "", ""
904+ }
905+ }
906+
889 if submitted := ed.HandleKey(k); submitted {907 if submitted := ed.HandleKey(k); submitted {
890 raw := strings.TrimRight(ed.SubmitValue(), "\n")908 raw := strings.TrimRight(ed.SubmitValue(), "\n")
891 909

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