feat(tui): collapse multi-line pastes to a placeholder token
A 200-line paste (log, stack trace, config blob) used to expand
the editor to 200 visible rows, burying the rest of the tui and
making the prompt awkward to edit. Now a paste of 3+ lines is
replaced in the editor with a short token like
[pasted text #1 +56 lines]
while the full body is stashed behind the scenes and expanded
back in right before the turn goes to the agent. Single-line
and two-line pastes fall through to the old inline insert path
(drag-dropped file paths, short snippets) so those still work
as before.
Implementation
Editor gained two private fields:
- pastes map[int]string full bodies keyed by id
- pasteSeq int monotonic id counter
KeyPaste branch: on content with >= 2 newlines, allocates the
next id, stores the raw body, inserts the placeholder token
at the cursor. Everything else stays on the existing
quotePastedFilePaths path.
Editor.Value() returns what's visible (placeholder).
Editor.SubmitValue() new method: runs pastePlaceholderRE over
the visible text and swaps each match
for its stored body. Called once at
submit time; non-destructive so history
recall (up-arrow) still shows the
placeholder form, not the replay.
Editor.Clear() drops the pastes map + resets pasteSeq
so ids from a previous turn can't leak.
SetValue() same reset: pastes map is tied to the
visible text and a SetValue replaces it.
interactive.go the one caller that reads the editor to
build a prompt now reads Value() for the
history entry and SubmitValue() for the
string that goes to the agent.
Tests
paste_collapse_test.go covers:
- placeholder shape + SubmitValue expansion
- single-line and two-line pastes skip the collapse path
- two separate pastes get distinct ids, both expand
- Clear() resets the map + counter
Tweaked the pre-existing TestEditorCursorAfterMultilinePaste
and TestEditorCursorAfterLongPasteWithWrap to use Editor.Insert
directly so they keep testing wrap / cursor math rather than
accidentally exercising the new collapse path.- commit
- 51fd11fce67010a01bb7247ca4e82a41ffd867f7
- author
- patriceckhart · · 2026-04-21 06:47 UTC
- parent
- 916a6f7
- signature
- unsigned
- refs
- tag: v0.0.75
4 files · +210 -11
M internal/agent/modes/interactive.go +8 -2
| @@ -1181,11 +1181,17 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1181 | 1181 | } | |
| 1182 | 1182 | ||
| 1183 | 1183 | if submit := i.ed.HandleKey(k); submit { | |
| 1184 | - | text := strings.TrimRight(i.ed.Value(), "\n") | |
| 1184 | + | // SubmitValue() expands any [paste #N +L lines] placeholders | |
| 1185 | + | // back into the pasted bodies; Value() is what the user | |
| 1186 | + | // sees on screen. History stores the visible text so the | |
| 1187 | + | // up-arrow recall shows the placeholder, not a 500-line | |
| 1188 | + | // replay. | |
| 1189 | + | visible := strings.TrimRight(i.ed.Value(), "\n") | |
| 1190 | + | text := strings.TrimRight(i.ed.SubmitValue(), "\n") | |
| 1185 | 1191 | if text == "" { | |
| 1186 | 1192 | return false | |
| 1187 | 1193 | } | |
| 1188 | - | i.ed.PushHistory(text) | |
| 1194 | + | i.ed.PushHistory(visible) | |
| 1189 | 1195 | i.ed.Clear() | |
| 1190 | 1196 | i.suggest.Reset() | |
| 1191 | 1197 | ||
| @@ -1181,11 +1181,17 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1181 | } | 1181 | } | ||
| 1182 | 1182 | ||||
| 1183 | if submit := i.ed.HandleKey(k); submit { | 1183 | if submit := i.ed.HandleKey(k); submit { | ||
| 1184 | - | text := strings.TrimRight(i.ed.Value(), "\n") | 1184 | + | // SubmitValue() expands any [paste #N +L lines] placeholders |
| 1185 | + | // back into the pasted bodies; Value() is what the user | |||
| 1186 | + | // sees on screen. History stores the visible text so the | |||
| 1187 | + | // up-arrow recall shows the placeholder, not a 500-line | |||
| 1188 | + | // replay. | |||
| 1189 | + | visible := strings.TrimRight(i.ed.Value(), "\n") | |||
| 1190 | + | text := strings.TrimRight(i.ed.SubmitValue(), "\n") | |||
| 1185 | if text == "" { | 1191 | if text == "" { | ||
| 1186 | return false | 1192 | return false | ||
| 1187 | } | 1193 | } | ||
| 1188 | - | i.ed.PushHistory(text) | 1194 | + | i.ed.PushHistory(visible) |
| 1189 | i.ed.Clear() | 1195 | i.ed.Clear() | ||
| 1190 | i.suggest.Reset() | 1196 | i.suggest.Reset() | ||
| 1191 | 1197 | ||||
M internal/tui/editor.go +114 -7
| @@ -1,7 +1,9 @@ | |||
| 1 | 1 | package tui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "net/url" | |
| 6 | + | "regexp" | |
| 5 | 7 | "strings" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/mattn/go-runewidth" | |
| @@ -25,6 +27,16 @@ type Editor struct { | |||
| 25 | 27 | History []string | |
| 26 | 28 | histIdx int // -1 means "editing current buffer" | |
| 27 | 29 | savedDraft string | |
| 30 | + | ||
| 31 | + | // pastes stores the full content of every multi-line paste, | |
| 32 | + | // keyed by the id embedded in the visible placeholder token. | |
| 33 | + | // Pasted text is collapsed to "[paste #N +L lines]" in the | |
| 34 | + | // editor so a 500-line drop doesn't explode the input area; | |
| 35 | + | // SubmitValue() expands placeholders back to their real bodies | |
| 36 | + | // right before the prompt goes to the agent. The map is reset | |
| 37 | + | // on Clear() so stale pastes never leak into a follow-up turn. | |
| 38 | + | pastes map[int]string | |
| 39 | + | pasteSeq int | |
| 28 | 40 | } | |
| 29 | 41 | ||
| 30 | 42 | // NewEditor returns an empty editor with the given prompt. | |
| @@ -36,10 +48,34 @@ func NewEditor(prompt string) *Editor { | |||
| 36 | 48 | } | |
| 37 | 49 | } | |
| 38 | 50 | ||
| 39 | - | // Value returns the buffer as a single string. | |
| 51 | + | // Value returns the buffer as a single string, WITHOUT expanding | |
| 52 | + | // paste placeholders. Used for anything that should reflect what's | |
| 53 | + | // visible on screen (history, slash-command detection, editor | |
| 54 | + | // state). For the string that actually goes to the agent, use | |
| 55 | + | // SubmitValue(), which expands each [paste #N +L lines] token | |
| 56 | + | // back into the full pasted body. | |
| 40 | 57 | func (e *Editor) Value() string { return strings.Join(e.Lines, "\n") } | |
| 41 | 58 | ||
| 59 | + | // SubmitValue returns the buffer with every paste placeholder | |
| 60 | + | // expanded to its stored body. Call once at submit time; the | |
| 61 | + | // expansion is lossless (placeholders are only injected in | |
| 62 | + | // HandleKey for KeyPaste with multi-line content). | |
| 63 | + | // | |
| 64 | + | // Expansion is non-destructive: the internal paste map isn't | |
| 65 | + | // touched. Clear() is what resets both the placeholder text and | |
| 66 | + | // the map, and the caller already calls Clear() right after | |
| 67 | + | // reading SubmitValue() as part of the submit flow. | |
| 68 | + | func (e *Editor) SubmitValue() string { | |
| 69 | + | raw := e.Value() | |
| 70 | + | if len(e.pastes) == 0 || !strings.Contains(raw, "[pasted text #") { | |
| 71 | + | return raw | |
| 72 | + | } | |
| 73 | + | return expandPastePlaceholders(raw, e.pastes) | |
| 74 | + | } | |
| 75 | + | ||
| 42 | 76 | // SetValue replaces the buffer and places the cursor at the end. | |
| 77 | + | // Also drops any stored pastes because the placeholders they back | |
| 78 | + | // are now gone from the visible text. | |
| 43 | 79 | func (e *Editor) SetValue(s string) { | |
| 44 | 80 | e.Lines = strings.Split(s, "\n") | |
| 45 | 81 | if len(e.Lines) == 0 { | |
| @@ -48,6 +84,8 @@ func (e *Editor) SetValue(s string) { | |||
| 48 | 84 | e.CursorR = len(e.Lines) - 1 | |
| 49 | 85 | e.CursorC = runeLen(e.Lines[e.CursorR]) | |
| 50 | 86 | e.histIdx = -1 | |
| 87 | + | e.pastes = nil | |
| 88 | + | e.pasteSeq = 0 | |
| 51 | 89 | } | |
| 52 | 90 | ||
| 53 | 91 | // Clear resets the buffer. | |
| @@ -125,12 +163,30 @@ func (e *Editor) HandleKey(k Key) (submit bool) { | |||
| 125 | 163 | case KeyCtrlW: | |
| 126 | 164 | e.deleteWord() | |
| 127 | 165 | case KeyPaste: | |
| 128 | - | // macOS Terminal / iTerm / Ghostty deliver drag-dropped files | |
| 129 | - | // as bracketed-paste text. Detect that pattern and wrap the | |
| 130 | - | // path(s) in single quotes so the agent sees them as one | |
| 131 | - | // argument and any spaces / parens in the filename don't | |
| 132 | - | // confuse downstream tool calls. | |
| 133 | - | e.insert(quotePastedFilePaths(k.Paste)) | |
| 166 | + | // Large multi-line pastes are collapsed to a short | |
| 167 | + | // placeholder token so the editor doesn't balloon to | |
| 168 | + | // hundreds of rows. The full body is stashed in e.pastes, | |
| 169 | + | // keyed by a monotonically increasing id, and swapped | |
| 170 | + | // back in at submit time via SubmitValue. Threshold: | |
| 171 | + | // two or more newlines triggers collapse; one-liners and | |
| 172 | + | // drag-dropped file paths fall through to the original | |
| 173 | + | // insert path (including file-path quoting). | |
| 174 | + | if pasteShouldCollapse(k.Paste) { | |
| 175 | + | if e.pastes == nil { | |
| 176 | + | e.pastes = map[int]string{} | |
| 177 | + | } | |
| 178 | + | e.pasteSeq++ | |
| 179 | + | id := e.pasteSeq | |
| 180 | + | e.pastes[id] = k.Paste | |
| 181 | + | placeholder := fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(k.Paste)) | |
| 182 | + | e.insert(placeholder) | |
| 183 | + | } else { | |
| 184 | + | // macOS Terminal / iTerm / Ghostty deliver drag-dropped | |
| 185 | + | // files as bracketed-paste text. Detect that pattern | |
| 186 | + | // and wrap the path(s) in single quotes so the agent | |
| 187 | + | // sees them as one argument. | |
| 188 | + | e.insert(quotePastedFilePaths(k.Paste)) | |
| 189 | + | } | |
| 134 | 190 | case KeyEsc: | |
| 135 | 191 | e.Clear() | |
| 136 | 192 | } | |
| @@ -831,3 +887,54 @@ func max(a, b int) int { | |||
| 831 | 887 | } | |
| 832 | 888 | return b | |
| 833 | 889 | } | |
| 890 | + | ||
| 891 | + | // pasteShouldCollapse reports whether a pasted chunk is big enough | |
| 892 | + | // to deserve a placeholder token instead of being inserted verbatim. | |
| 893 | + | // Two or more newlines (i.e. three-plus lines) is the cutoff: that | |
| 894 | + | // covers real multi-line code/log pastes while leaving single-line | |
| 895 | + | // file-drop paths and two-line copy-pastes of a sentence alone. | |
| 896 | + | func pasteShouldCollapse(s string) bool { | |
| 897 | + | return strings.Count(s, "\n") >= 2 | |
| 898 | + | } | |
| 899 | + | ||
| 900 | + | // countLines returns the number of visual lines in s. A trailing | |
| 901 | + | // newline is not counted as an extra empty line so | |
| 902 | + | // "foo\nbar\n" reads as 2 lines (what the user expects in the | |
| 903 | + | // "+N lines" summary) instead of 3. | |
| 904 | + | func countLines(s string) int { | |
| 905 | + | if s == "" { | |
| 906 | + | return 0 | |
| 907 | + | } | |
| 908 | + | n := strings.Count(s, "\n") | |
| 909 | + | if !strings.HasSuffix(s, "\n") { | |
| 910 | + | n++ | |
| 911 | + | } | |
| 912 | + | return n | |
| 913 | + | } | |
| 914 | + | ||
| 915 | + | // pastePlaceholderRE matches the "[pasted text #N +L lines]" token | |
| 916 | + | // that collapsed pastes leave in the editor. Capture group 1 is | |
| 917 | + | // the numeric id used to look up the full body in e.pastes. | |
| 918 | + | var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) \+\d+ lines?\]`) | |
| 919 | + | ||
| 920 | + | // expandPastePlaceholders returns raw with every paste token | |
| 921 | + | // swapped for the body stored under its id in pastes. Tokens | |
| 922 | + | // whose id isn't in the map are left as-is (user deleted the | |
| 923 | + | // corresponding entry somehow, or the id is spurious user text | |
| 924 | + | // that happens to match the shape). | |
| 925 | + | func expandPastePlaceholders(raw string, pastes map[int]string) string { | |
| 926 | + | return pastePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string { | |
| 927 | + | groups := pastePlaceholderRE.FindStringSubmatch(match) | |
| 928 | + | if len(groups) < 2 { | |
| 929 | + | return match | |
| 930 | + | } | |
| 931 | + | var id int | |
| 932 | + | if _, err := fmt.Sscanf(groups[1], "%d", &id); err != nil { | |
| 933 | + | return match | |
| 934 | + | } | |
| 935 | + | if body, ok := pastes[id]; ok { | |
| 936 | + | return body | |
| 937 | + | } | |
| 938 | + | return match | |
| 939 | + | }) | |
| 940 | + | } | |
| @@ -1,7 +1,9 @@ | |||||
| 1 | package tui | 1 | package tui | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "fmt" | |||
| 4 | "net/url" | 5 | "net/url" | ||
| 6 | + | "regexp" | |||
| 5 | "strings" | 7 | "strings" | ||
| 6 | 8 | ||||
| 7 | "github.com/mattn/go-runewidth" | 9 | "github.com/mattn/go-runewidth" | ||
| @@ -25,6 +27,16 @@ type Editor struct { | |||||
| 25 | History []string | 27 | History []string | ||
| 26 | histIdx int // -1 means "editing current buffer" | 28 | histIdx int // -1 means "editing current buffer" | ||
| 27 | savedDraft string | 29 | savedDraft string | ||
| 30 | + | ||||
| 31 | + | // pastes stores the full content of every multi-line paste, | |||
| 32 | + | // keyed by the id embedded in the visible placeholder token. | |||
| 33 | + | // Pasted text is collapsed to "[paste #N +L lines]" in the | |||
| 34 | + | // editor so a 500-line drop doesn't explode the input area; | |||
| 35 | + | // SubmitValue() expands placeholders back to their real bodies | |||
| 36 | + | // right before the prompt goes to the agent. The map is reset | |||
| 37 | + | // on Clear() so stale pastes never leak into a follow-up turn. | |||
| 38 | + | pastes map[int]string | |||
| 39 | + | pasteSeq int | |||
| 28 | } | 40 | } | ||
| 29 | 41 | ||||
| 30 | // NewEditor returns an empty editor with the given prompt. | 42 | // NewEditor returns an empty editor with the given prompt. | ||
| @@ -36,10 +48,34 @@ func NewEditor(prompt string) *Editor { | |||||
| 36 | } | 48 | } | ||
| 37 | } | 49 | } | ||
| 38 | 50 | ||||
| 39 | - | // Value returns the buffer as a single string. | 51 | + | // Value returns the buffer as a single string, WITHOUT expanding |
| 52 | + | // paste placeholders. Used for anything that should reflect what's | |||
| 53 | + | // visible on screen (history, slash-command detection, editor | |||
| 54 | + | // state). For the string that actually goes to the agent, use | |||
| 55 | + | // SubmitValue(), which expands each [paste #N +L lines] token | |||
| 56 | + | // back into the full pasted body. | |||
| 40 | func (e *Editor) Value() string { return strings.Join(e.Lines, "\n") } | 57 | func (e *Editor) Value() string { return strings.Join(e.Lines, "\n") } | ||
| 41 | 58 | ||||
| 59 | + | // SubmitValue returns the buffer with every paste placeholder | |||
| 60 | + | // expanded to its stored body. Call once at submit time; the | |||
| 61 | + | // expansion is lossless (placeholders are only injected in | |||
| 62 | + | // HandleKey for KeyPaste with multi-line content). | |||
| 63 | + | // | |||
| 64 | + | // Expansion is non-destructive: the internal paste map isn't | |||
| 65 | + | // touched. Clear() is what resets both the placeholder text and | |||
| 66 | + | // the map, and the caller already calls Clear() right after | |||
| 67 | + | // reading SubmitValue() as part of the submit flow. | |||
| 68 | + | func (e *Editor) SubmitValue() string { | |||
| 69 | + | raw := e.Value() | |||
| 70 | + | if len(e.pastes) == 0 || !strings.Contains(raw, "[pasted text #") { | |||
| 71 | + | return raw | |||
| 72 | + | } | |||
| 73 | + | return expandPastePlaceholders(raw, e.pastes) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 42 | // SetValue replaces the buffer and places the cursor at the end. | 76 | // SetValue replaces the buffer and places the cursor at the end. | ||
| 77 | + | // Also drops any stored pastes because the placeholders they back | |||
| 78 | + | // are now gone from the visible text. | |||
| 43 | func (e *Editor) SetValue(s string) { | 79 | func (e *Editor) SetValue(s string) { | ||
| 44 | e.Lines = strings.Split(s, "\n") | 80 | e.Lines = strings.Split(s, "\n") | ||
| 45 | if len(e.Lines) == 0 { | 81 | if len(e.Lines) == 0 { | ||
| @@ -48,6 +84,8 @@ func (e *Editor) SetValue(s string) { | |||||
| 48 | e.CursorR = len(e.Lines) - 1 | 84 | e.CursorR = len(e.Lines) - 1 | ||
| 49 | e.CursorC = runeLen(e.Lines[e.CursorR]) | 85 | e.CursorC = runeLen(e.Lines[e.CursorR]) | ||
| 50 | e.histIdx = -1 | 86 | e.histIdx = -1 | ||
| 87 | + | e.pastes = nil | |||
| 88 | + | e.pasteSeq = 0 | |||
| 51 | } | 89 | } | ||
| 52 | 90 | ||||
| 53 | // Clear resets the buffer. | 91 | // Clear resets the buffer. | ||
| @@ -125,12 +163,30 @@ func (e *Editor) HandleKey(k Key) (submit bool) { | |||||
| 125 | case KeyCtrlW: | 163 | case KeyCtrlW: | ||
| 126 | e.deleteWord() | 164 | e.deleteWord() | ||
| 127 | case KeyPaste: | 165 | case KeyPaste: | ||
| 128 | - | // macOS Terminal / iTerm / Ghostty deliver drag-dropped files | 166 | + | // Large multi-line pastes are collapsed to a short |
| 129 | - | // as bracketed-paste text. Detect that pattern and wrap the | 167 | + | // placeholder token so the editor doesn't balloon to |
| 130 | - | // path(s) in single quotes so the agent sees them as one | 168 | + | // hundreds of rows. The full body is stashed in e.pastes, |
| 131 | - | // argument and any spaces / parens in the filename don't | 169 | + | // keyed by a monotonically increasing id, and swapped |
| 132 | - | // confuse downstream tool calls. | 170 | + | // back in at submit time via SubmitValue. Threshold: |
| 133 | - | e.insert(quotePastedFilePaths(k.Paste)) | 171 | + | // two or more newlines triggers collapse; one-liners and |
| 172 | + | // drag-dropped file paths fall through to the original | |||
| 173 | + | // insert path (including file-path quoting). | |||
| 174 | + | if pasteShouldCollapse(k.Paste) { | |||
| 175 | + | if e.pastes == nil { | |||
| 176 | + | e.pastes = map[int]string{} | |||
| 177 | + | } | |||
| 178 | + | e.pasteSeq++ | |||
| 179 | + | id := e.pasteSeq | |||
| 180 | + | e.pastes[id] = k.Paste | |||
| 181 | + | placeholder := fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(k.Paste)) | |||
| 182 | + | e.insert(placeholder) | |||
| 183 | + | } else { | |||
| 184 | + | // macOS Terminal / iTerm / Ghostty deliver drag-dropped | |||
| 185 | + | // files as bracketed-paste text. Detect that pattern | |||
| 186 | + | // and wrap the path(s) in single quotes so the agent | |||
| 187 | + | // sees them as one argument. | |||
| 188 | + | e.insert(quotePastedFilePaths(k.Paste)) | |||
| 189 | + | } | |||
| 134 | case KeyEsc: | 190 | case KeyEsc: | ||
| 135 | e.Clear() | 191 | e.Clear() | ||
| 136 | } | 192 | } | ||
| @@ -831,3 +887,54 @@ func max(a, b int) int { | |||||
| 831 | } | 887 | } | ||
| 832 | return b | 888 | return b | ||
| 833 | } | 889 | } | ||
| 890 | + | ||||
| 891 | + | // pasteShouldCollapse reports whether a pasted chunk is big enough | |||
| 892 | + | // to deserve a placeholder token instead of being inserted verbatim. | |||
| 893 | + | // Two or more newlines (i.e. three-plus lines) is the cutoff: that | |||
| 894 | + | // covers real multi-line code/log pastes while leaving single-line | |||
| 895 | + | // file-drop paths and two-line copy-pastes of a sentence alone. | |||
| 896 | + | func pasteShouldCollapse(s string) bool { | |||
| 897 | + | return strings.Count(s, "\n") >= 2 | |||
| 898 | + | } | |||
| 899 | + | ||||
| 900 | + | // countLines returns the number of visual lines in s. A trailing | |||
| 901 | + | // newline is not counted as an extra empty line so | |||
| 902 | + | // "foo\nbar\n" reads as 2 lines (what the user expects in the | |||
| 903 | + | // "+N lines" summary) instead of 3. | |||
| 904 | + | func countLines(s string) int { | |||
| 905 | + | if s == "" { | |||
| 906 | + | return 0 | |||
| 907 | + | } | |||
| 908 | + | n := strings.Count(s, "\n") | |||
| 909 | + | if !strings.HasSuffix(s, "\n") { | |||
| 910 | + | n++ | |||
| 911 | + | } | |||
| 912 | + | return n | |||
| 913 | + | } | |||
| 914 | + | ||||
| 915 | + | // pastePlaceholderRE matches the "[pasted text #N +L lines]" token | |||
| 916 | + | // that collapsed pastes leave in the editor. Capture group 1 is | |||
| 917 | + | // the numeric id used to look up the full body in e.pastes. | |||
| 918 | + | var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) \+\d+ lines?\]`) | |||
| 919 | + | ||||
| 920 | + | // expandPastePlaceholders returns raw with every paste token | |||
| 921 | + | // swapped for the body stored under its id in pastes. Tokens | |||
| 922 | + | // whose id isn't in the map are left as-is (user deleted the | |||
| 923 | + | // corresponding entry somehow, or the id is spurious user text | |||
| 924 | + | // that happens to match the shape). | |||
| 925 | + | func expandPastePlaceholders(raw string, pastes map[int]string) string { | |||
| 926 | + | return pastePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string { | |||
| 927 | + | groups := pastePlaceholderRE.FindStringSubmatch(match) | |||
| 928 | + | if len(groups) < 2 { | |||
| 929 | + | return match | |||
| 930 | + | } | |||
| 931 | + | var id int | |||
| 932 | + | if _, err := fmt.Sscanf(groups[1], "%d", &id); err != nil { | |||
| 933 | + | return match | |||
| 934 | + | } | |||
| 935 | + | if body, ok := pastes[id]; ok { | |||
| 936 | + | return body | |||
| 937 | + | } | |||
| 938 | + | return match | |||
| 939 | + | }) | |||
| 940 | + | } | |||
A internal/tui/paste_collapse_test.go +80 -0
| @@ -0,0 +1,80 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // TestPasteCollapseInsertsPlaceholder verifies that a multi-line | |
| 9 | + | // paste gets collapsed to the "[paste #N +L lines]" placeholder | |
| 10 | + | // in the editor buffer, leaving the full body behind the scenes. | |
| 11 | + | func TestPasteCollapseInsertsPlaceholder(t *testing.T) { | |
| 12 | + | e := NewEditor("▌ ") | |
| 13 | + | body := "line1\nline2\nline3\nline4" | |
| 14 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: body}) | |
| 15 | + | ||
| 16 | + | got := e.Value() | |
| 17 | + | want := "[pasted text #1 +4 lines]" | |
| 18 | + | if got != want { | |
| 19 | + | t.Fatalf("editor Value: want %q, got %q", want, got) | |
| 20 | + | } | |
| 21 | + | if e.SubmitValue() != body { | |
| 22 | + | t.Fatalf("SubmitValue didn't expand placeholder: got %q", e.SubmitValue()) | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // TestPasteCollapseSingleLineFallthrough ensures short pastes are | |
| 27 | + | // NOT collapsed — single-line drag-drop file paths and short | |
| 28 | + | // two-line snippets should appear inline so the user can edit | |
| 29 | + | // them in place. | |
| 30 | + | func TestPasteCollapseSingleLineFallthrough(t *testing.T) { | |
| 31 | + | e := NewEditor("▌ ") | |
| 32 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: "hello world"}) | |
| 33 | + | if strings.Contains(e.Value(), "[pasted text #") { | |
| 34 | + | t.Errorf("single-line paste should not collapse, got %q", e.Value()) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | e2 := NewEditor("▌ ") | |
| 38 | + | e2.HandleKey(Key{Kind: KeyPaste, Paste: "line1\nline2"}) | |
| 39 | + | if strings.Contains(e2.Value(), "[pasted text #") { | |
| 40 | + | t.Errorf("two-line paste should not collapse, got %q", e2.Value()) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // TestPasteCollapseSequentialIDs makes sure two separate pastes get | |
| 45 | + | // distinct ids and both expand correctly in SubmitValue. | |
| 46 | + | func TestPasteCollapseSequentialIDs(t *testing.T) { | |
| 47 | + | e := NewEditor("▌ ") | |
| 48 | + | a := "aaa\nbbb\nccc" | |
| 49 | + | b := "xxx\nyyy\nzzz" | |
| 50 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: a}) | |
| 51 | + | e.Insert(" ") | |
| 52 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: b}) | |
| 53 | + | ||
| 54 | + | visible := e.Value() | |
| 55 | + | if !strings.Contains(visible, "[pasted text #1 +3 lines]") || | |
| 56 | + | !strings.Contains(visible, "[pasted text #2 +3 lines]") { | |
| 57 | + | t.Fatalf("expected two placeholders in %q", visible) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | full := e.SubmitValue() | |
| 61 | + | if !strings.Contains(full, a) || !strings.Contains(full, b) { | |
| 62 | + | t.Fatalf("SubmitValue missing bodies: %q", full) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // TestPasteCollapseClearResetsMap verifies that Clear drops the | |
| 67 | + | // stored pastes so stale ids can't leak into a follow-up turn | |
| 68 | + | // (same placeholder number could otherwise be reused to expand to | |
| 69 | + | // the wrong body). | |
| 70 | + | func TestPasteCollapseClearResetsMap(t *testing.T) { | |
| 71 | + | e := NewEditor("▌ ") | |
| 72 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: "a\nb\nc"}) | |
| 73 | + | e.Clear() | |
| 74 | + | ||
| 75 | + | // A fresh paste must start at id #1 again. | |
| 76 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: "d\ne\nf"}) | |
| 77 | + | if !strings.Contains(e.Value(), "[pasted text #1 ") { | |
| 78 | + | t.Errorf("Clear didn't reset pasteSeq: %q", e.Value()) | |
| 79 | + | } | |
| 80 | + | } | |
| @@ -0,0 +1,80 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // TestPasteCollapseInsertsPlaceholder verifies that a multi-line | |||
| 9 | + | // paste gets collapsed to the "[paste #N +L lines]" placeholder | |||
| 10 | + | // in the editor buffer, leaving the full body behind the scenes. | |||
| 11 | + | func TestPasteCollapseInsertsPlaceholder(t *testing.T) { | |||
| 12 | + | e := NewEditor("▌ ") | |||
| 13 | + | body := "line1\nline2\nline3\nline4" | |||
| 14 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: body}) | |||
| 15 | + | ||||
| 16 | + | got := e.Value() | |||
| 17 | + | want := "[pasted text #1 +4 lines]" | |||
| 18 | + | if got != want { | |||
| 19 | + | t.Fatalf("editor Value: want %q, got %q", want, got) | |||
| 20 | + | } | |||
| 21 | + | if e.SubmitValue() != body { | |||
| 22 | + | t.Fatalf("SubmitValue didn't expand placeholder: got %q", e.SubmitValue()) | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // TestPasteCollapseSingleLineFallthrough ensures short pastes are | |||
| 27 | + | // NOT collapsed — single-line drag-drop file paths and short | |||
| 28 | + | // two-line snippets should appear inline so the user can edit | |||
| 29 | + | // them in place. | |||
| 30 | + | func TestPasteCollapseSingleLineFallthrough(t *testing.T) { | |||
| 31 | + | e := NewEditor("▌ ") | |||
| 32 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: "hello world"}) | |||
| 33 | + | if strings.Contains(e.Value(), "[pasted text #") { | |||
| 34 | + | t.Errorf("single-line paste should not collapse, got %q", e.Value()) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | e2 := NewEditor("▌ ") | |||
| 38 | + | e2.HandleKey(Key{Kind: KeyPaste, Paste: "line1\nline2"}) | |||
| 39 | + | if strings.Contains(e2.Value(), "[pasted text #") { | |||
| 40 | + | t.Errorf("two-line paste should not collapse, got %q", e2.Value()) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // TestPasteCollapseSequentialIDs makes sure two separate pastes get | |||
| 45 | + | // distinct ids and both expand correctly in SubmitValue. | |||
| 46 | + | func TestPasteCollapseSequentialIDs(t *testing.T) { | |||
| 47 | + | e := NewEditor("▌ ") | |||
| 48 | + | a := "aaa\nbbb\nccc" | |||
| 49 | + | b := "xxx\nyyy\nzzz" | |||
| 50 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: a}) | |||
| 51 | + | e.Insert(" ") | |||
| 52 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: b}) | |||
| 53 | + | ||||
| 54 | + | visible := e.Value() | |||
| 55 | + | if !strings.Contains(visible, "[pasted text #1 +3 lines]") || | |||
| 56 | + | !strings.Contains(visible, "[pasted text #2 +3 lines]") { | |||
| 57 | + | t.Fatalf("expected two placeholders in %q", visible) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | full := e.SubmitValue() | |||
| 61 | + | if !strings.Contains(full, a) || !strings.Contains(full, b) { | |||
| 62 | + | t.Fatalf("SubmitValue missing bodies: %q", full) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // TestPasteCollapseClearResetsMap verifies that Clear drops the | |||
| 67 | + | // stored pastes so stale ids can't leak into a follow-up turn | |||
| 68 | + | // (same placeholder number could otherwise be reused to expand to | |||
| 69 | + | // the wrong body). | |||
| 70 | + | func TestPasteCollapseClearResetsMap(t *testing.T) { | |||
| 71 | + | e := NewEditor("▌ ") | |||
| 72 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: "a\nb\nc"}) | |||
| 73 | + | e.Clear() | |||
| 74 | + | ||||
| 75 | + | // A fresh paste must start at id #1 again. | |||
| 76 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: "d\ne\nf"}) | |||
| 77 | + | if !strings.Contains(e.Value(), "[pasted text #1 ") { | |||
| 78 | + | t.Errorf("Clear didn't reset pasteSeq: %q", e.Value()) | |||
| 79 | + | } | |||
| 80 | + | } | |||
M internal/tui/wrap_test.go +8 -2
| @@ -34,9 +34,13 @@ func TestWrapLineFirstContinuationHasIndent(t *testing.T) { | |||
| 34 | 34 | // TestEditorCursorAfterMultilinePaste is the downstream test: the | |
| 35 | 35 | // rendered editor cursor must land at the logical end of the paste, | |
| 36 | 36 | // with its visual column equal to leadW + runewidth(last-line). | |
| 37 | + | // | |
| 38 | + | // Uses Insert directly to bypass the KeyPaste collapse path (which | |
| 39 | + | // would turn this into a placeholder token); the test's concern is | |
| 40 | + | // wrap / cursor math, not paste behaviour. | |
| 37 | 41 | func TestEditorCursorAfterMultilinePaste(t *testing.T) { | |
| 38 | 42 | e := NewEditor("▌ ") | |
| 39 | - | e.HandleKey(Key{Kind: KeyPaste, Paste: "aaa\nbbb\nccccc"}) | |
| 43 | + | e.Insert("aaa\nbbb\nccccc") | |
| 40 | 44 | ||
| 41 | 45 | // Logical end: last line "ccccc", cursor past its 5 runes. | |
| 42 | 46 | if e.CursorR != 2 || e.CursorC != 5 { | |
| @@ -64,7 +68,9 @@ func TestEditorCursorAfterMultilinePaste(t *testing.T) { | |||
| 64 | 68 | // terminal cursor drifted when typed after pasting a wrapped path. | |
| 65 | 69 | func TestEditorCursorAfterLongPasteWithWrap(t *testing.T) { | |
| 66 | 70 | e := NewEditor("▌ ") | |
| 67 | - | e.HandleKey(Key{Kind: KeyPaste, Paste: "this is a very long line that should wrap\nshort"}) | |
| 71 | + | // Direct Insert bypasses the multi-line collapse path; the | |
| 72 | + | // concern here is wrap-column math, not the collapse logic. | |
| 73 | + | e.Insert("this is a very long line that should wrap\nshort") | |
| 68 | 74 | ||
| 69 | 75 | lines, row, col := e.Render(30) | |
| 70 | 76 | ||
| @@ -34,9 +34,13 @@ func TestWrapLineFirstContinuationHasIndent(t *testing.T) { | |||||
| 34 | // TestEditorCursorAfterMultilinePaste is the downstream test: the | 34 | // TestEditorCursorAfterMultilinePaste is the downstream test: the | ||
| 35 | // rendered editor cursor must land at the logical end of the paste, | 35 | // rendered editor cursor must land at the logical end of the paste, | ||
| 36 | // with its visual column equal to leadW + runewidth(last-line). | 36 | // with its visual column equal to leadW + runewidth(last-line). | ||
| 37 | + | // | |||
| 38 | + | // Uses Insert directly to bypass the KeyPaste collapse path (which | |||
| 39 | + | // would turn this into a placeholder token); the test's concern is | |||
| 40 | + | // wrap / cursor math, not paste behaviour. | |||
| 37 | func TestEditorCursorAfterMultilinePaste(t *testing.T) { | 41 | func TestEditorCursorAfterMultilinePaste(t *testing.T) { | ||
| 38 | e := NewEditor("▌ ") | 42 | e := NewEditor("▌ ") | ||
| 39 | - | e.HandleKey(Key{Kind: KeyPaste, Paste: "aaa\nbbb\nccccc"}) | 43 | + | e.Insert("aaa\nbbb\nccccc") |
| 40 | 44 | ||||
| 41 | // Logical end: last line "ccccc", cursor past its 5 runes. | 45 | // Logical end: last line "ccccc", cursor past its 5 runes. | ||
| 42 | if e.CursorR != 2 || e.CursorC != 5 { | 46 | if e.CursorR != 2 || e.CursorC != 5 { | ||
| @@ -64,7 +68,9 @@ func TestEditorCursorAfterMultilinePaste(t *testing.T) { | |||||
| 64 | // terminal cursor drifted when typed after pasting a wrapped path. | 68 | // terminal cursor drifted when typed after pasting a wrapped path. | ||
| 65 | func TestEditorCursorAfterLongPasteWithWrap(t *testing.T) { | 69 | func TestEditorCursorAfterLongPasteWithWrap(t *testing.T) { | ||
| 66 | e := NewEditor("▌ ") | 70 | e := NewEditor("▌ ") | ||
| 67 | - | e.HandleKey(Key{Kind: KeyPaste, Paste: "this is a very long line that should wrap\nshort"}) | 71 | + | // Direct Insert bypasses the multi-line collapse path; the |
| 72 | + | // concern here is wrap-column math, not the collapse logic. | |||
| 73 | + | e.Insert("this is a very long line that should wrap\nshort") | |||
| 68 | 74 | ||||
| 69 | lines, row, col := e.Render(30) | 75 | lines, row, col := e.Render(30) | ||
| 70 | 76 | ||||