detached 8a38171

feat(tui): collapse long file paths to [file:basename] chips

Drag-dropped or pasted file paths are shown as compact [file:name] chips in the editor instead of the full path. Expanded back to the full quoted path on submit. URLs are never collapsed.
commit
8a38171cc823acc4618d6643f97e42b9453893e2
author
patriceckhart · · 2026-04-25 08:20 UTC
parent
8841800
signature
unsigned
refs
tag: v0.1.10
1 file · +70 -6
M internal/tui/editor.go +70 -6
@@ -3,6 +3,7 @@ package tui
33 import (
44 "fmt"
55 "net/url"
6+ "path/filepath"
67 "regexp"
78 "strings"
89
@@ -39,6 +40,12 @@ type Editor struct {
3940 // on Clear() so stale pastes never leak into a follow-up turn.
4041 pastes map[int]string
4142 pasteSeq int
43+
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.
47+ files map[int]string
48+ fileSeq int
4249 }
4350
4451 // NewEditor returns an empty editor with the given prompt.
@@ -68,10 +75,13 @@ func (e *Editor) Value() string { return strings.Join(e.Lines, "\n") }
6875 // reading SubmitValue() as part of the submit flow.
6976 func (e *Editor) SubmitValue() string {
7077 raw := e.Value()
71- if len(e.pastes) == 0 || !strings.Contains(raw, "[pasted text #") {
72- return raw
78+ if len(e.pastes) > 0 && strings.Contains(raw, "[pasted text #") {
79+ raw = expandPastePlaceholders(raw, e.pastes)
80+ }
81+ if len(e.files) > 0 && strings.Contains(raw, "[file:") {
82+ raw = expandFilePlaceholders(raw, e.files)
7383 }
74- return expandPastePlaceholders(raw, e.pastes)
84+ return raw
7585 }
7686
7787 // SetValue replaces the buffer and places the cursor at the end.
@@ -86,6 +96,8 @@ func (e *Editor) SetValue(s string) {
8696 e.CursorC = runeLen(e.Lines[e.CursorR])
8797 e.pastes = nil
8898 e.pasteSeq = 0
99+ e.files = nil
100+ e.fileSeq = 0
89101 }
90102
91103 // Clear resets the buffer.
@@ -176,9 +188,9 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
176188 } else {
177189 // macOS Terminal / iTerm / Ghostty deliver drag-dropped
178190 // files as bracketed-paste text. Detect that pattern
179- // and wrap the path(s) in single quotes so the agent
180- // sees them as one argument.
181- e.insert(quotePastedFilePaths(k.Paste))
191+ // and collapse long paths to a [file:basename] chip.
192+ inserted := e.collapseOrQuoteFilePaths(k.Paste)
193+ e.insert(inserted)
182194 }
183195 case KeyEsc:
184196 e.Clear()
@@ -1134,6 +1146,7 @@ func countLines(s string) int {
11341146 // body in e.pastes; the rest of the token is free-form and gets
11351147 // discarded during expansion.
11361148 var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) (?:\+\d+ lines?|\d+ chars?)\]`)
1149+var filePlaceholderRE = regexp.MustCompile(`\[file:(\d+):[^\]]+\]`)
11371150
11381151 // expandPastePlaceholders returns raw with every paste token
11391152 // swapped for the body stored under its id in pastes. Tokens
@@ -1156,3 +1169,54 @@ func expandPastePlaceholders(raw string, pastes map[int]string) string {
11561169 return match
11571170 })
11581171 }
1172+
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 {
1176+ return filePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {
1177+ groups := filePlaceholderRE.FindStringSubmatch(match)
1178+ if len(groups) < 2 {
1179+ return match
1180+ }
1181+ var id int
1182+ if _, err := fmt.Sscanf(groups[1], "%d", &id); err != nil {
1183+ return match
1184+ }
1185+ if path, ok := files[id]; ok {
1186+ return path
1187+ }
1188+ return match
1189+ })
1190+}
1191+
1192+// collapseOrQuoteFilePaths checks if the paste is a file path and
1193+// collapses it to a [file:basename] chip. URLs are left as-is.
1194+// Non-path pastes fall through to quotePastedFilePaths.
1195+func (e *Editor) collapseOrQuoteFilePaths(paste string) string {
1196+ if paste == "" || strings.ContainsRune(paste, '\n') {
1197+ return quotePastedFilePaths(paste)
1198+ }
1199+ trimmed := strings.TrimSpace(paste)
1200+ // Don't collapse URLs.
1201+ if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
1202+ return paste
1203+ }
1204+ // Check if it's a single file path.
1205+ p, ok := normalisePathToken(trimmed)
1206+ if !ok {
1207+ return quotePastedFilePaths(paste)
1208+ }
1209+ // Only collapse if the path is long enough to benefit.
1210+ base := filepath.Base(p)
1211+ if len(p) <= len(base)+5 {
1212+ // Short path like "./foo.txt" - just quote it.
1213+ return singleQuote(p)
1214+ }
1215+ if e.files == nil {
1216+ e.files = map[int]string{}
1217+ }
1218+ e.fileSeq++
1219+ id := e.fileSeq
1220+ e.files[id] = singleQuote(p)
1221+ return fmt.Sprintf("[file:%d:%s]", id, base)
1222+}
@@ -3,6 +3,7 @@ package tui
3 import (3 import (
4 "fmt"4 "fmt"
5 "net/url"5 "net/url"
6+ "path/filepath"
6 "regexp"7 "regexp"
7 "strings"8 "strings"
8 9
@@ -39,6 +40,12 @@ type Editor struct {
39 // on Clear() so stale pastes never leak into a follow-up turn.40 // on Clear() so stale pastes never leak into a follow-up turn.
40 pastes map[int]string41 pastes map[int]string
41 pasteSeq int42 pasteSeq int
43+
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.
47+ files map[int]string
48+ fileSeq int
42 }49 }
43 50
44 // NewEditor returns an empty editor with the given prompt.51 // NewEditor returns an empty editor with the given prompt.
@@ -68,10 +75,13 @@ func (e *Editor) Value() string { return strings.Join(e.Lines, "\n") }
68 // reading SubmitValue() as part of the submit flow.75 // reading SubmitValue() as part of the submit flow.
69 func (e *Editor) SubmitValue() string {76 func (e *Editor) SubmitValue() string {
70 raw := e.Value()77 raw := e.Value()
71- if len(e.pastes) == 0 || !strings.Contains(raw, "[pasted text #") {78+ if len(e.pastes) > 0 && strings.Contains(raw, "[pasted text #") {
72- return raw79+ raw = expandPastePlaceholders(raw, e.pastes)
80+ }
81+ if len(e.files) > 0 && strings.Contains(raw, "[file:") {
82+ raw = expandFilePlaceholders(raw, e.files)
73 }83 }
74- return expandPastePlaceholders(raw, e.pastes)84+ return raw
75 }85 }
76 86
77 // SetValue replaces the buffer and places the cursor at the end.87 // SetValue replaces the buffer and places the cursor at the end.
@@ -86,6 +96,8 @@ func (e *Editor) SetValue(s string) {
86 e.CursorC = runeLen(e.Lines[e.CursorR])96 e.CursorC = runeLen(e.Lines[e.CursorR])
87 e.pastes = nil97 e.pastes = nil
88 e.pasteSeq = 098 e.pasteSeq = 0
99+ e.files = nil
100+ e.fileSeq = 0
89 }101 }
90 102
91 // Clear resets the buffer.103 // Clear resets the buffer.
@@ -176,9 +188,9 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
176 } else {188 } else {
177 // macOS Terminal / iTerm / Ghostty deliver drag-dropped189 // macOS Terminal / iTerm / Ghostty deliver drag-dropped
178 // files as bracketed-paste text. Detect that pattern190 // files as bracketed-paste text. Detect that pattern
179- // and wrap the path(s) in single quotes so the agent191+ // and collapse long paths to a [file:basename] chip.
180- // sees them as one argument.192+ inserted := e.collapseOrQuoteFilePaths(k.Paste)
181- e.insert(quotePastedFilePaths(k.Paste))193+ e.insert(inserted)
182 }194 }
183 case KeyEsc:195 case KeyEsc:
184 e.Clear()196 e.Clear()
@@ -1134,6 +1146,7 @@ func countLines(s string) int {
1134 // body in e.pastes; the rest of the token is free-form and gets1146 // body in e.pastes; the rest of the token is free-form and gets
1135 // discarded during expansion.1147 // discarded during expansion.
1136 var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) (?:\+\d+ lines?|\d+ chars?)\]`)1148 var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) (?:\+\d+ lines?|\d+ chars?)\]`)
1149+var filePlaceholderRE = regexp.MustCompile(`\[file:(\d+):[^\]]+\]`)
1137 1150
1138 // expandPastePlaceholders returns raw with every paste token1151 // expandPastePlaceholders returns raw with every paste token
1139 // swapped for the body stored under its id in pastes. Tokens1152 // swapped for the body stored under its id in pastes. Tokens
@@ -1156,3 +1169,54 @@ func expandPastePlaceholders(raw string, pastes map[int]string) string {
1156 return match1169 return match
1157 })1170 })
1158 }1171 }
1172+
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 {
1176+ return filePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {
1177+ groups := filePlaceholderRE.FindStringSubmatch(match)
1178+ if len(groups) < 2 {
1179+ return match
1180+ }
1181+ var id int
1182+ if _, err := fmt.Sscanf(groups[1], "%d", &id); err != nil {
1183+ return match
1184+ }
1185+ if path, ok := files[id]; ok {
1186+ return path
1187+ }
1188+ return match
1189+ })
1190+}
1191+
1192+// collapseOrQuoteFilePaths checks if the paste is a file path and
1193+// collapses it to a [file:basename] chip. URLs are left as-is.
1194+// Non-path pastes fall through to quotePastedFilePaths.
1195+func (e *Editor) collapseOrQuoteFilePaths(paste string) string {
1196+ if paste == "" || strings.ContainsRune(paste, '\n') {
1197+ return quotePastedFilePaths(paste)
1198+ }
1199+ trimmed := strings.TrimSpace(paste)
1200+ // Don't collapse URLs.
1201+ if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
1202+ return paste
1203+ }
1204+ // Check if it's a single file path.
1205+ p, ok := normalisePathToken(trimmed)
1206+ if !ok {
1207+ return quotePastedFilePaths(paste)
1208+ }
1209+ // Only collapse if the path is long enough to benefit.
1210+ base := filepath.Base(p)
1211+ if len(p) <= len(base)+5 {
1212+ // Short path like "./foo.txt" - just quote it.
1213+ return singleQuote(p)
1214+ }
1215+ if e.files == nil {
1216+ e.files = map[int]string{}
1217+ }
1218+ e.fileSeq++
1219+ id := e.fileSeq
1220+ e.files[id] = singleQuote(p)
1221+ return fmt.Sprintf("[file:%d:%s]", id, base)
1222+}

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