detached 51fd11f

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) {
11811181 }
11821182
11831183 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")
11851191 if text == "" {
11861192 return false
11871193 }
1188- i.ed.PushHistory(text)
1194+ i.ed.PushHistory(visible)
11891195 i.ed.Clear()
11901196 i.suggest.Reset()
11911197
@@ -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 false1192 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 @@
11 package tui
22
33 import (
4+ "fmt"
45 "net/url"
6+ "regexp"
57 "strings"
68
79 "github.com/mattn/go-runewidth"
@@ -25,6 +27,16 @@ type Editor struct {
2527 History []string
2628 histIdx int // -1 means "editing current buffer"
2729 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
2840 }
2941
3042 // NewEditor returns an empty editor with the given prompt.
@@ -36,10 +48,34 @@ func NewEditor(prompt string) *Editor {
3648 }
3749 }
3850
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.
4057 func (e *Editor) Value() string { return strings.Join(e.Lines, "\n") }
4158
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+
4276 // 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.
4379 func (e *Editor) SetValue(s string) {
4480 e.Lines = strings.Split(s, "\n")
4581 if len(e.Lines) == 0 {
@@ -48,6 +84,8 @@ func (e *Editor) SetValue(s string) {
4884 e.CursorR = len(e.Lines) - 1
4985 e.CursorC = runeLen(e.Lines[e.CursorR])
5086 e.histIdx = -1
87+ e.pastes = nil
88+ e.pasteSeq = 0
5189 }
5290
5391 // Clear resets the buffer.
@@ -125,12 +163,30 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
125163 case KeyCtrlW:
126164 e.deleteWord()
127165 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+ }
134190 case KeyEsc:
135191 e.Clear()
136192 }
@@ -831,3 +887,54 @@ func max(a, b int) int {
831887 }
832888 return b
833889 }
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 tui1 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 []string27 History []string
26 histIdx int // -1 means "editing current buffer"28 histIdx int // -1 means "editing current buffer"
27 savedDraft string29 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) - 184 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 = -186 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 files166+ // Large multi-line pastes are collapsed to a short
129- // as bracketed-paste text. Detect that pattern and wrap the167+ // placeholder token so the editor doesn't balloon to
130- // path(s) in single quotes so the agent sees them as one168+ // hundreds of rows. The full body is stashed in e.pastes,
131- // argument and any spaces / parens in the filename don't169+ // 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 b888 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) {
3434 // TestEditorCursorAfterMultilinePaste is the downstream test: the
3535 // rendered editor cursor must land at the logical end of the paste,
3636 // 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.
3741 func TestEditorCursorAfterMultilinePaste(t *testing.T) {
3842 e := NewEditor("▌ ")
39- e.HandleKey(Key{Kind: KeyPaste, Paste: "aaa\nbbb\nccccc"})
43+ e.Insert("aaa\nbbb\nccccc")
4044
4145 // Logical end: last line "ccccc", cursor past its 5 runes.
4246 if e.CursorR != 2 || e.CursorC != 5 {
@@ -64,7 +68,9 @@ func TestEditorCursorAfterMultilinePaste(t *testing.T) {
6468 // terminal cursor drifted when typed after pasting a wrapped path.
6569 func TestEditorCursorAfterLongPasteWithWrap(t *testing.T) {
6670 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")
6874
6975 lines, row, col := e.Render(30)
7076
@@ -34,9 +34,13 @@ func TestWrapLineFirstContinuationHasIndent(t *testing.T) {
34 // TestEditorCursorAfterMultilinePaste is the downstream test: the34 // 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

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