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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "net/url" | |
| 6 | + | "path/filepath" | |
| 6 | 7 | "regexp" | |
| 7 | 8 | "strings" | |
| 8 | 9 | ||
| @@ -39,6 +40,12 @@ type Editor struct { | |||
| 39 | 40 | // on Clear() so stale pastes never leak into a follow-up turn. | |
| 40 | 41 | pastes map[int]string | |
| 41 | 42 | 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 | 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 | 75 | // reading SubmitValue() as part of the submit flow. | |
| 69 | 76 | func (e *Editor) SubmitValue() string { | |
| 70 | 77 | 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) | |
| 73 | 83 | } | |
| 74 | - | return expandPastePlaceholders(raw, e.pastes) | |
| 84 | + | return raw | |
| 75 | 85 | } | |
| 76 | 86 | ||
| 77 | 87 | // SetValue replaces the buffer and places the cursor at the end. | |
| @@ -86,6 +96,8 @@ func (e *Editor) SetValue(s string) { | |||
| 86 | 96 | e.CursorC = runeLen(e.Lines[e.CursorR]) | |
| 87 | 97 | e.pastes = nil | |
| 88 | 98 | e.pasteSeq = 0 | |
| 99 | + | e.files = nil | |
| 100 | + | e.fileSeq = 0 | |
| 89 | 101 | } | |
| 90 | 102 | ||
| 91 | 103 | // Clear resets the buffer. | |
| @@ -176,9 +188,9 @@ func (e *Editor) HandleKey(k Key) (submit bool) { | |||
| 176 | 188 | } else { | |
| 177 | 189 | // macOS Terminal / iTerm / Ghostty deliver drag-dropped | |
| 178 | 190 | // 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) | |
| 182 | 194 | } | |
| 183 | 195 | case KeyEsc: | |
| 184 | 196 | e.Clear() | |
| @@ -1134,6 +1146,7 @@ func countLines(s string) int { | |||
| 1134 | 1146 | // body in e.pastes; the rest of the token is free-form and gets | |
| 1135 | 1147 | // discarded during expansion. | |
| 1136 | 1148 | var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) (?:\+\d+ lines?|\d+ chars?)\]`) | |
| 1149 | + | var filePlaceholderRE = regexp.MustCompile(`\[file:(\d+):[^\]]+\]`) | |
| 1137 | 1150 | ||
| 1138 | 1151 | // expandPastePlaceholders returns raw with every paste token | |
| 1139 | 1152 | // 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 | 1169 | 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 | + | } | |
| @@ -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]string | 41 | pastes map[int]string | ||
| 41 | pasteSeq int | 42 | 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 raw | 79 | + | 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 = nil | 97 | e.pastes = nil | ||
| 88 | e.pasteSeq = 0 | 98 | 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-dropped | 189 | // macOS Terminal / iTerm / Ghostty deliver drag-dropped | ||
| 178 | // files as bracketed-paste text. Detect that pattern | 190 | // files as bracketed-paste text. Detect that pattern | ||
| 179 | - | // and wrap the path(s) in single quotes so the agent | 191 | + | // 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 gets | 1146 | // 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 token | 1151 | // expandPastePlaceholders returns raw with every paste token | ||
| 1139 | // swapped for the body stored under its id in pastes. Tokens | 1152 | // 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 match | 1169 | 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 | + | } | |||