tweak(tui): two-threshold paste collapse with line-vs-char shape

Before: any paste with >= 2 newlines (3+ lines) collapsed to
[pasted text #N +L lines]. That fired too eagerly on short
three-line snippets the user wants to see inline, and never
fired on a 2000-character single-line dump that bloats the
editor just as badly.

New rule matches the shape widely used in other TUIs:

  collapse when
    lines > 10         OR
    chars > 1000

Both triggers produce distinct placeholder shapes so you can
see at a glance which dimension tripped it:

  [pasted text #1 +12 lines]   line trigger
  [pasted text #1 1500 chars]  char trigger

When both triggers hit (e.g. 12 lines of 400 chars each) the
line-count shape wins \u2014 "+12 lines" reads more informatively
than a raw character count for multi-line content.

Implementation:

  - pasteCollapseLineThreshold  = 10
  - pasteCollapseCharThreshold  = 1000
  - pasteShouldCollapse checks countLines(s) > 10 || len(s) > 1000
  - formatPastePlaceholder picks the shape; line trigger wins
    on ties
  - pastePlaceholderRE widened with a non-capturing alternation
    (\+\d+ lines?|\d+ chars?) so expansion works for both
    shapes; capture group 1 is still just the id
  - the strings.Contains fast-path in SubmitValue still matches
    both shapes because both start with the same prefix

Tests rewritten around the new thresholds:

  - TestPasteCollapseLineTrigger    11 lines -> +11 lines marker
  - TestPasteCollapseCharTrigger    1500 chars, 1 line -> N chars
  - TestPasteCollapseLinePrecedence 12 lines of 400 chars each ->
                                    line shape wins
  - TestPasteCollapseFallthrough    1 line / 2 lines / 10 lines /
                                    1000 chars / 5 lines under
                                    caps all stay inline
  - TestPasteCollapseSequentialIDs  mixed-shape placeholders
                                    coexist, both expand
  - TestPasteCollapseClearResetsMap unchanged logic, updated body
                                    to trigger the new threshold
This commit is contained in:
patriceckhart 2026-04-21 17:13:23 +02:00
parent 755ca7ccdf
commit c4150ce630
2 changed files with 124 additions and 38 deletions

View file

@ -172,8 +172,7 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
e.pasteSeq++
id := e.pasteSeq
e.pastes[id] = k.Paste
placeholder := fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(k.Paste))
e.insert(placeholder)
e.insert(formatPastePlaceholder(id, k.Paste))
} else {
// macOS Terminal / iTerm / Ghostty deliver drag-dropped
// files as bracketed-paste text. Detect that pattern
@ -958,13 +957,41 @@ func max(a, b int) int {
return b
}
// pasteCollapseLineThreshold and pasteCollapseCharThreshold govern
// when a bracketed paste gets collapsed to a [pasted text #N …]
// placeholder instead of being inserted inline. Either trigger
// alone is enough — a 500-line log dump and a 1200-character
// one-line log entry both bloat the editor in ways the user
// doesn't want to scroll through while composing a prompt.
const (
pasteCollapseLineThreshold = 10
pasteCollapseCharThreshold = 1000
)
// pasteShouldCollapse reports whether a pasted chunk is big enough
// to deserve a placeholder token instead of being inserted verbatim.
// Two or more newlines (i.e. three-plus lines) is the cutoff: that
// covers real multi-line code/log pastes while leaving single-line
// file-drop paths and two-line copy-pastes of a sentence alone.
// Collapse on > 10 lines OR > 1000 characters, whichever fires
// first.
func pasteShouldCollapse(s string) bool {
return strings.Count(s, "\n") >= 2
return countLines(s) > pasteCollapseLineThreshold || len(s) > pasteCollapseCharThreshold
}
// formatPastePlaceholder builds the visible marker for a
// collapsed paste. Two shapes:
//
// [pasted text #N +L lines] — used when the line count is the
// trigger (multi-line dumps)
// [pasted text #N C chars] — used when the character count is
// the trigger (long single-line
// or near-single-line pastes)
//
// Line-triggered takes precedence so a 12-line 4000-char paste
// reads as "+12 lines", not "4000 chars".
func formatPastePlaceholder(id int, body string) string {
if countLines(body) > pasteCollapseLineThreshold {
return fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(body))
}
return fmt.Sprintf("[pasted text #%d %d chars]", id, len(body))
}
// countLines returns the number of visual lines in s. A trailing
@ -982,10 +1009,16 @@ func countLines(s string) int {
return n
}
// pastePlaceholderRE matches the "[pasted text #N +L lines]" token
// that collapsed pastes leave in the editor. Capture group 1 is
// the numeric id used to look up the full body in e.pastes.
var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) \+\d+ lines?\]`)
// pastePlaceholderRE matches both placeholder shapes the editor
// emits for a collapsed paste:
//
// [pasted text #N +L lines] — multi-line trigger
// [pasted text #N C chars] — long-char trigger
//
// Capture group 1 is the numeric id used to look up the full
// body in e.pastes; the rest of the token is free-form and gets
// discarded during expansion.
var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) (?:\+\d+ lines?|\d+ chars?)\]`)
// expandPastePlaceholders returns raw with every paste token
// swapped for the body stored under its id in pastes. Tokens

View file

@ -1,20 +1,32 @@
package tui
import (
"fmt"
"strings"
"testing"
)
// TestPasteCollapseInsertsPlaceholder verifies that a multi-line
// paste gets collapsed to the "[paste #N +L lines]" placeholder
// in the editor buffer, leaving the full body behind the scenes.
func TestPasteCollapseInsertsPlaceholder(t *testing.T) {
// makeLines returns a body with n lines of short text — useful for
// exercising the line-count trigger without also tripping the
// character-count one.
func makeLines(n int) string {
parts := make([]string, n)
for i := range parts {
parts[i] = fmt.Sprintf("line %d", i+1)
}
return strings.Join(parts, "\n")
}
// TestPasteCollapseLineTrigger verifies that a paste with more than
// ten lines gets collapsed to the "+L lines" placeholder shape.
// The full body is preserved and expanded back by SubmitValue.
func TestPasteCollapseLineTrigger(t *testing.T) {
e := NewEditor("▌ ")
body := "line1\nline2\nline3\nline4"
body := makeLines(11)
e.HandleKey(Key{Kind: KeyPaste, Paste: body})
got := e.Value()
want := "[pasted text #1 +4 lines]"
want := "[pasted text #1 +11 lines]"
if got != want {
t.Fatalf("editor Value: want %q, got %q", want, got)
}
@ -23,38 +35,79 @@ func TestPasteCollapseInsertsPlaceholder(t *testing.T) {
}
}
// TestPasteCollapseSingleLineFallthrough ensures short pastes are
// NOT collapsed — single-line drag-drop file paths and short
// two-line snippets should appear inline so the user can edit
// them in place.
func TestPasteCollapseSingleLineFallthrough(t *testing.T) {
// TestPasteCollapseCharTrigger verifies that a paste with more than
// 1000 characters but few enough lines collapses to the "C chars"
// placeholder shape (long single-line / near-single-line dumps).
func TestPasteCollapseCharTrigger(t *testing.T) {
e := NewEditor("▌ ")
e.HandleKey(Key{Kind: KeyPaste, Paste: "hello world"})
if strings.Contains(e.Value(), "[pasted text #") {
t.Errorf("single-line paste should not collapse, got %q", e.Value())
}
body := strings.Repeat("a", 1500) // 1 line, 1500 chars
e.HandleKey(Key{Kind: KeyPaste, Paste: body})
e2 := NewEditor("▌ ")
e2.HandleKey(Key{Kind: KeyPaste, Paste: "line1\nline2"})
if strings.Contains(e2.Value(), "[pasted text #") {
t.Errorf("two-line paste should not collapse, got %q", e2.Value())
got := e.Value()
want := "[pasted text #1 1500 chars]"
if got != want {
t.Fatalf("editor Value: want %q, got %q", want, got)
}
if e.SubmitValue() != body {
t.Fatalf("SubmitValue didn't expand placeholder: got %q", e.SubmitValue())
}
}
// TestPasteCollapseSequentialIDs makes sure two separate pastes get
// distinct ids and both expand correctly in SubmitValue.
// TestPasteCollapseLinePrecedence verifies that when a paste trips
// both thresholds, the line-count marker wins. "12 lines, 4000
// chars" should read as "+12 lines", not "4000 chars".
func TestPasteCollapseLinePrecedence(t *testing.T) {
e := NewEditor("▌ ")
// 12 lines, each 400 chars = 12 lines, ~4800 chars.
parts := make([]string, 12)
for i := range parts {
parts[i] = strings.Repeat("x", 400)
}
body := strings.Join(parts, "\n")
e.HandleKey(Key{Kind: KeyPaste, Paste: body})
if !strings.HasPrefix(e.Value(), "[pasted text #1 +12 lines") {
t.Errorf("want line-trigger placeholder, got %q", e.Value())
}
}
// TestPasteCollapseFallthrough ensures short pastes (under both
// thresholds) are NOT collapsed — 1-10 lines with < 1000 chars
// should appear inline so the user can edit them in place.
func TestPasteCollapseFallthrough(t *testing.T) {
cases := map[string]string{
"single line": "hello world",
"two lines": "line1\nline2",
"ten lines (at limit)": makeLines(10),
"exactly 1000 chars": strings.Repeat("a", 1000),
"multiline under caps": "aaa\nbbb\nccc\nddd\neee",
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
e := NewEditor("▌ ")
e.HandleKey(Key{Kind: KeyPaste, Paste: body})
if strings.Contains(e.Value(), "[pasted text #") {
t.Errorf("%s should NOT collapse, got %q", name, e.Value())
}
})
}
}
// TestPasteCollapseSequentialIDs makes sure two separate large
// pastes get distinct ids and both expand correctly in
// SubmitValue, even when they use different placeholder shapes.
func TestPasteCollapseSequentialIDs(t *testing.T) {
e := NewEditor("▌ ")
a := "aaa\nbbb\nccc"
b := "xxx\nyyy\nzzz"
a := makeLines(12) // line trigger
b := strings.Repeat("y", 1500) // char trigger, single line
e.HandleKey(Key{Kind: KeyPaste, Paste: a})
e.Insert(" ")
e.HandleKey(Key{Kind: KeyPaste, Paste: b})
visible := e.Value()
if !strings.Contains(visible, "[pasted text #1 +3 lines]") ||
!strings.Contains(visible, "[pasted text #2 +3 lines]") {
t.Fatalf("expected two placeholders in %q", visible)
if !strings.Contains(visible, "[pasted text #1 +12 lines]") ||
!strings.Contains(visible, "[pasted text #2 1500 chars]") {
t.Fatalf("expected mixed-shape placeholders in %q", visible)
}
full := e.SubmitValue()
@ -69,11 +122,11 @@ func TestPasteCollapseSequentialIDs(t *testing.T) {
// the wrong body).
func TestPasteCollapseClearResetsMap(t *testing.T) {
e := NewEditor("▌ ")
e.HandleKey(Key{Kind: KeyPaste, Paste: "a\nb\nc"})
e.HandleKey(Key{Kind: KeyPaste, Paste: makeLines(11)})
e.Clear()
// A fresh paste must start at id #1 again.
e.HandleKey(Key{Kind: KeyPaste, Paste: "d\ne\nf"})
e.HandleKey(Key{Kind: KeyPaste, Paste: makeLines(11)})
if !strings.Contains(e.Value(), "[pasted text #1 ") {
t.Errorf("Clear didn't reset pasteSeq: %q", e.Value())
}