diff --git a/internal/tui/editor.go b/internal/tui/editor.go index b7e39bf..dace906 100644 --- a/internal/tui/editor.go +++ b/internal/tui/editor.go @@ -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 diff --git a/internal/tui/paste_collapse_test.go b/internal/tui/paste_collapse_test.go index f197028..2f49fde 100644 --- a/internal/tui/paste_collapse_test.go +++ b/internal/tui/paste_collapse_test.go @@ -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()) }