mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-27 13:56:33 +02:00
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.
This commit is contained in:
parent
916a6f71d1
commit
51fd11fce6
4 changed files with 210 additions and 11 deletions
|
|
@ -1181,11 +1181,17 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
|
|||
}
|
||||
|
||||
if submit := i.ed.HandleKey(k); submit {
|
||||
text := strings.TrimRight(i.ed.Value(), "\n")
|
||||
// SubmitValue() expands any [paste #N +L lines] placeholders
|
||||
// back into the pasted bodies; Value() is what the user
|
||||
// sees on screen. History stores the visible text so the
|
||||
// up-arrow recall shows the placeholder, not a 500-line
|
||||
// replay.
|
||||
visible := strings.TrimRight(i.ed.Value(), "\n")
|
||||
text := strings.TrimRight(i.ed.SubmitValue(), "\n")
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
i.ed.PushHistory(text)
|
||||
i.ed.PushHistory(visible)
|
||||
i.ed.Clear()
|
||||
i.suggest.Reset()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/mattn/go-runewidth"
|
||||
|
|
@ -25,6 +27,16 @@ type Editor struct {
|
|||
History []string
|
||||
histIdx int // -1 means "editing current buffer"
|
||||
savedDraft string
|
||||
|
||||
// pastes stores the full content of every multi-line paste,
|
||||
// keyed by the id embedded in the visible placeholder token.
|
||||
// Pasted text is collapsed to "[paste #N +L lines]" in the
|
||||
// editor so a 500-line drop doesn't explode the input area;
|
||||
// SubmitValue() expands placeholders back to their real bodies
|
||||
// right before the prompt goes to the agent. The map is reset
|
||||
// on Clear() so stale pastes never leak into a follow-up turn.
|
||||
pastes map[int]string
|
||||
pasteSeq int
|
||||
}
|
||||
|
||||
// NewEditor returns an empty editor with the given prompt.
|
||||
|
|
@ -36,10 +48,34 @@ func NewEditor(prompt string) *Editor {
|
|||
}
|
||||
}
|
||||
|
||||
// Value returns the buffer as a single string.
|
||||
// Value returns the buffer as a single string, WITHOUT expanding
|
||||
// paste placeholders. Used for anything that should reflect what's
|
||||
// visible on screen (history, slash-command detection, editor
|
||||
// state). For the string that actually goes to the agent, use
|
||||
// SubmitValue(), which expands each [paste #N +L lines] token
|
||||
// back into the full pasted body.
|
||||
func (e *Editor) Value() string { return strings.Join(e.Lines, "\n") }
|
||||
|
||||
// SubmitValue returns the buffer with every paste placeholder
|
||||
// expanded to its stored body. Call once at submit time; the
|
||||
// expansion is lossless (placeholders are only injected in
|
||||
// HandleKey for KeyPaste with multi-line content).
|
||||
//
|
||||
// Expansion is non-destructive: the internal paste map isn't
|
||||
// touched. Clear() is what resets both the placeholder text and
|
||||
// the map, and the caller already calls Clear() right after
|
||||
// reading SubmitValue() as part of the submit flow.
|
||||
func (e *Editor) SubmitValue() string {
|
||||
raw := e.Value()
|
||||
if len(e.pastes) == 0 || !strings.Contains(raw, "[pasted text #") {
|
||||
return raw
|
||||
}
|
||||
return expandPastePlaceholders(raw, e.pastes)
|
||||
}
|
||||
|
||||
// SetValue replaces the buffer and places the cursor at the end.
|
||||
// Also drops any stored pastes because the placeholders they back
|
||||
// are now gone from the visible text.
|
||||
func (e *Editor) SetValue(s string) {
|
||||
e.Lines = strings.Split(s, "\n")
|
||||
if len(e.Lines) == 0 {
|
||||
|
|
@ -48,6 +84,8 @@ func (e *Editor) SetValue(s string) {
|
|||
e.CursorR = len(e.Lines) - 1
|
||||
e.CursorC = runeLen(e.Lines[e.CursorR])
|
||||
e.histIdx = -1
|
||||
e.pastes = nil
|
||||
e.pasteSeq = 0
|
||||
}
|
||||
|
||||
// Clear resets the buffer.
|
||||
|
|
@ -125,12 +163,30 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
|
|||
case KeyCtrlW:
|
||||
e.deleteWord()
|
||||
case KeyPaste:
|
||||
// macOS Terminal / iTerm / Ghostty deliver drag-dropped files
|
||||
// as bracketed-paste text. Detect that pattern and wrap the
|
||||
// path(s) in single quotes so the agent sees them as one
|
||||
// argument and any spaces / parens in the filename don't
|
||||
// confuse downstream tool calls.
|
||||
e.insert(quotePastedFilePaths(k.Paste))
|
||||
// Large multi-line pastes are collapsed to a short
|
||||
// placeholder token so the editor doesn't balloon to
|
||||
// hundreds of rows. The full body is stashed in e.pastes,
|
||||
// keyed by a monotonically increasing id, and swapped
|
||||
// back in at submit time via SubmitValue. Threshold:
|
||||
// two or more newlines triggers collapse; one-liners and
|
||||
// drag-dropped file paths fall through to the original
|
||||
// insert path (including file-path quoting).
|
||||
if pasteShouldCollapse(k.Paste) {
|
||||
if e.pastes == nil {
|
||||
e.pastes = map[int]string{}
|
||||
}
|
||||
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)
|
||||
} else {
|
||||
// macOS Terminal / iTerm / Ghostty deliver drag-dropped
|
||||
// files as bracketed-paste text. Detect that pattern
|
||||
// and wrap the path(s) in single quotes so the agent
|
||||
// sees them as one argument.
|
||||
e.insert(quotePastedFilePaths(k.Paste))
|
||||
}
|
||||
case KeyEsc:
|
||||
e.Clear()
|
||||
}
|
||||
|
|
@ -831,3 +887,54 @@ func max(a, b int) int {
|
|||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// 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.
|
||||
func pasteShouldCollapse(s string) bool {
|
||||
return strings.Count(s, "\n") >= 2
|
||||
}
|
||||
|
||||
// countLines returns the number of visual lines in s. A trailing
|
||||
// newline is not counted as an extra empty line so
|
||||
// "foo\nbar\n" reads as 2 lines (what the user expects in the
|
||||
// "+N lines" summary) instead of 3.
|
||||
func countLines(s string) int {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
n := strings.Count(s, "\n")
|
||||
if !strings.HasSuffix(s, "\n") {
|
||||
n++
|
||||
}
|
||||
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?\]`)
|
||||
|
||||
// expandPastePlaceholders returns raw with every paste token
|
||||
// swapped for the body stored under its id in pastes. Tokens
|
||||
// whose id isn't in the map are left as-is (user deleted the
|
||||
// corresponding entry somehow, or the id is spurious user text
|
||||
// that happens to match the shape).
|
||||
func expandPastePlaceholders(raw string, pastes map[int]string) string {
|
||||
return pastePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {
|
||||
groups := pastePlaceholderRE.FindStringSubmatch(match)
|
||||
if len(groups) < 2 {
|
||||
return match
|
||||
}
|
||||
var id int
|
||||
if _, err := fmt.Sscanf(groups[1], "%d", &id); err != nil {
|
||||
return match
|
||||
}
|
||||
if body, ok := pastes[id]; ok {
|
||||
return body
|
||||
}
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
|
|
|||
80
internal/tui/paste_collapse_test.go
Normal file
80
internal/tui/paste_collapse_test.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"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) {
|
||||
e := NewEditor("▌ ")
|
||||
body := "line1\nline2\nline3\nline4"
|
||||
e.HandleKey(Key{Kind: KeyPaste, Paste: body})
|
||||
|
||||
got := e.Value()
|
||||
want := "[pasted text #1 +4 lines]"
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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())
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPasteCollapseSequentialIDs makes sure two separate pastes get
|
||||
// distinct ids and both expand correctly in SubmitValue.
|
||||
func TestPasteCollapseSequentialIDs(t *testing.T) {
|
||||
e := NewEditor("▌ ")
|
||||
a := "aaa\nbbb\nccc"
|
||||
b := "xxx\nyyy\nzzz"
|
||||
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)
|
||||
}
|
||||
|
||||
full := e.SubmitValue()
|
||||
if !strings.Contains(full, a) || !strings.Contains(full, b) {
|
||||
t.Fatalf("SubmitValue missing bodies: %q", full)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPasteCollapseClearResetsMap verifies that Clear drops the
|
||||
// stored pastes so stale ids can't leak into a follow-up turn
|
||||
// (same placeholder number could otherwise be reused to expand to
|
||||
// the wrong body).
|
||||
func TestPasteCollapseClearResetsMap(t *testing.T) {
|
||||
e := NewEditor("▌ ")
|
||||
e.HandleKey(Key{Kind: KeyPaste, Paste: "a\nb\nc"})
|
||||
e.Clear()
|
||||
|
||||
// A fresh paste must start at id #1 again.
|
||||
e.HandleKey(Key{Kind: KeyPaste, Paste: "d\ne\nf"})
|
||||
if !strings.Contains(e.Value(), "[pasted text #1 ") {
|
||||
t.Errorf("Clear didn't reset pasteSeq: %q", e.Value())
|
||||
}
|
||||
}
|
||||
|
|
@ -34,9 +34,13 @@ func TestWrapLineFirstContinuationHasIndent(t *testing.T) {
|
|||
// TestEditorCursorAfterMultilinePaste is the downstream test: the
|
||||
// rendered editor cursor must land at the logical end of the paste,
|
||||
// with its visual column equal to leadW + runewidth(last-line).
|
||||
//
|
||||
// Uses Insert directly to bypass the KeyPaste collapse path (which
|
||||
// would turn this into a placeholder token); the test's concern is
|
||||
// wrap / cursor math, not paste behaviour.
|
||||
func TestEditorCursorAfterMultilinePaste(t *testing.T) {
|
||||
e := NewEditor("▌ ")
|
||||
e.HandleKey(Key{Kind: KeyPaste, Paste: "aaa\nbbb\nccccc"})
|
||||
e.Insert("aaa\nbbb\nccccc")
|
||||
|
||||
// Logical end: last line "ccccc", cursor past its 5 runes.
|
||||
if e.CursorR != 2 || e.CursorC != 5 {
|
||||
|
|
@ -64,7 +68,9 @@ func TestEditorCursorAfterMultilinePaste(t *testing.T) {
|
|||
// terminal cursor drifted when typed after pasting a wrapped path.
|
||||
func TestEditorCursorAfterLongPasteWithWrap(t *testing.T) {
|
||||
e := NewEditor("▌ ")
|
||||
e.HandleKey(Key{Kind: KeyPaste, Paste: "this is a very long line that should wrap\nshort"})
|
||||
// Direct Insert bypasses the multi-line collapse path; the
|
||||
// concern here is wrap-column math, not the collapse logic.
|
||||
e.Insert("this is a very long line that should wrap\nshort")
|
||||
|
||||
lines, row, col := e.Render(30)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue