Skip to content

Add numbered paste placeholders and true multi-row input - #699

Open
kk1987 wants to merge 7 commits into
antirez:mainfrom
kk1987:linenoise-paste-expand
Open

Add numbered paste placeholders and true multi-row input#699
kk1987 wants to merge 7 commits into
antirez:mainfrom
kk1987:linenoise-paste-expand

Conversation

@kk1987

@kk1987 kk1987 commented Aug 5, 2026

Copy link
Copy Markdown

This upgrades the line editing of the bundled linenoise fork, shared by the ds4 REPL and the ds4-agent TUI, in two related ways: Claude Code-style paste placeholders, and real multi-row rendering of hard newlines.

Real multi-row input. The editor previously modeled the input as one logical row: a hard newline that reached the terminal (typed with Ctrl+J, or recalled from multiline history) moved the cursor down without a carriage return (raw mode clears OPOST) and desynchronized the refresh row math, stair-stepping and corrupting the prompt. refreshMultiLine now derives its geometry from a newline-aware walk of the rendered text: every hard newline paints as a real terminal row (written as CR LF), and the row, cursor, and clear accounting is exact, including the deferred-wrap state terminals keep when text ends on the right margin. Ctrl+J is now a documented way to type a newline. In the paths confined to a single row — single-line mode and masked input — newlines render as a one-column marker (U+21B5, East Asian Neutral, exactly one column everywhere).

Short pastes go inline. A paste of up to 5 lines and under 200 bytes is inserted directly and shows as real rows. Previously any paste containing a newline was folded behind a placeholder.

Long pastes collapse to a numbered placeholder. [Pasted text #1 +123 lines] (or +N chars for a long single-line paste). Numbers are assigned in creation order per edited line and never renumbered, so #2 still names the same text after #1 is gone. History folds keep the old unnumbered [... N lines ...] text. The newest placeholder carries a (paste again to expand) suffix that disappears as soon as the buffer content changes or the line is submitted.

Paste again to expand. Pasting bytes that exactly match a fold reveals that text in place as real rows, fully editable, instead of inserting a duplicate. A screen-height guard measures the true expanded row count first: a big file pasted twice by mistake beeps and keeps the placeholder rather than pushing the prompt past the terminal.

No silent drops. Running out of the 16 fold slots used to discard the paste with only a beep; the text now goes in unfolded. The only refusals left are a paste over PASTE_MAX_BYTES or an edit buffer that genuinely cannot grow.

Enter semantics are unchanged: folds were always display-only and the real bytes are submitted exactly as before, so nothing downstream of the line editor changes.

Testing: tests/test_linenoise_paste.c drives the non-blocking editor API with a fake terminal (LINENOISE_ASSUME_TTY, pipe stdin, tmpfile stdout so emitted bytes can be asserted) — 81 checks covering the newline geometry (including a differential test asserting the new math reproduces the previous formulas exactly on newline-free input, so wrapping behavior for ordinary lines is provably unchanged), CR LF emission, marker rendering in single-line mode, numbered placeholders, expand/re-fold, the screen-height guard, fold-slot exhaustion, the >1MB refusal, history folds, and the transient hint. Wired into make test; make ds4 ds4-agent builds warning-free. Manual terminal checks were added to QA_BEFORE_RELEASES.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UazT9herrhVF4gFz3VwSar

kk1987 and others added 7 commits August 4, 2026 23:49
The editor models the input as one logical line, so a pasted or recalled
newline that reached the terminal desynced the refresh cursor math and
corrupted the display. Substitute U+21B5 for every newline outside a
fold, which is one column on every terminal and needs no escape
sequence, so horizontal scrolling can still slice the rendered text.

Byte offsets are no longer preserved by rendering, so translating the
edit position into a render position now scans the buffer. The no-fold
fast path is gone for the same reason, and the insert shortcut bails out
when the buffer already holds newlines: it accounts for width with
utf8StrWidth(), which counts them as zero columns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UazT9herrhVF4gFz3VwSar
A prompt holding several placeholders gave no way to tell them apart.
Each fold created by a paste now keeps a stable number, assigned in
creation order and never reused, so "[Pasted text antirez#2 +40 lines]" still
names the same text after antirez#1 is removed. The array stays sorted by
offset, hence the explicit id beside each range.

Folds rebuilt from a recalled history entry keep the old unnumbered
text: they were not pasted on this line, so a number would be a lie.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UazT9herrhVF4gFz3VwSar
Folded text had no way back: the bytes were in the buffer but nothing
could reveal them. Pasting bytes that exactly match a fold now drops
that fold instead of inserting a duplicate, showing the text with its
newlines as markers, with the cursor left where the paste would have
left it.

The revealed text is one long logical line, so a file pasted twice by
mistake could push the prompt past the terminal: measure the expanded
render first and keep the fold, with a beep, when it would not fit.

The newest placeholder advertises this with an inline suffix, dropped as
soon as the buffer content changes or the line is submitted, so the
frozen line above the answer shows the plain placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UazT9herrhVF4gFz3VwSar
Any paste containing a newline became a placeholder, so a three line
snippet was hidden behind "[... 3 pasted lines ...]" even though it fits
the prompt. Fold only what would take the prompt over: long text, or
more than PASTE_FOLD_LINES lines.

The collector also refused pastes that could not fit the remaining edit
buffer, and dropped every paste once the sixteen fold slots were used,
in both cases without a word. Collect the bytes with only the
PASTE_MAX_BYTES cap, since identical bytes may expand an existing fold
rather than be inserted, and let the single insert decide: text with no
free fold slot goes in unfolded, and only a full buffer beeps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UazT9herrhVF4gFz3VwSar
The paste behaviour of the line editor had no test at all, and nothing
told the user that a placeholder can be opened again. Add a unit test
driving the editor through its non blocking API with a fake terminal,
pinning the newline markers, the numbered placeholders, expanding by
pasting again, the screen height guard, the fold slot exhaustion and the
transient hint. Mention the gesture in both help screens and add the
manual checks to the release QA list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UazT9herrhVF4gFz3VwSar
The editor treated the input as one long logical line: rows and cursor
came from dividing the total display width by the terminal width, which
is only right while the text holds no newline. A newline typed with
Ctrl+J went out as a bare LF, and with OPOST off that drops a row while
keeping the column, so the input stair-stepped away and every later
redraw was off by the rows nobody had counted.

Walk the rendered text instead. Each hard newline opens a fresh row,
each segment wraps at the right margin, and the walk reports the row,
the column and the deferred wrap state terminals keep when text stops
exactly on the margin: knowing it is what lets a newline right after
such text resolve the wrap instead of skipping a row. The multi row
refresh writes CR LF for every newline and takes its clearing, cursor
and reserved area numbers from the same walk, so typed newlines, short
pastes and expanded placeholders are now edited as the rows they look
like, and the screen fit guard measures real rows.

The marker survives where several rows are impossible: the single row
refresh and masked input, which the render buffer now serves through a
flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UazT9herrhVF4gFz3VwSar
The gesture has always been there, undocumented and unusable: it is a
supported way to write a multi row prompt now that the editor paints
the rows. Add it to both help screens and give the release QA list the
multi row editing checks a terminal has to make by hand.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UazT9herrhVF4gFz3VwSar
@kk1987 kk1987 changed the title Show numbered paste placeholders and expand them on re-paste Add numbered paste placeholders and true multi-row input Aug 5, 2026
@kk1987

kk1987 commented Aug 5, 2026

Copy link
Copy Markdown
Author

Since opening this PR, two more commits landed (ed34429, 3a43c24): unfolded newlines are no longer shown as a marker in multi-line mode — refreshMultiLine now paints them as real terminal rows with exact geometry, which also makes Ctrl+J a proper, documented way to type a multiline prompt. The marker remains only in single-line mode and masked input. The description above has been updated to match, and the test suite grew from 46 to 81 checks (including a differential test proving the new row math is byte-for-byte equivalent to the old formulas on newline-free input).

🤖 Generated with Claude Code

https://claude.ai/code/session_01UazT9herrhVF4gFz3VwSar

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant