Skip to content

fix(claude): confirm an inline paste whose tail the TUI hard-wrapped - #1219

Merged
Juliusolsson05 merged 5 commits into
mainfrom
fix/claude-paste-tail-hard-wrap
Sep 25, 2026
Merged

Juliusolsson05 merged 5 commits into
mainfrom
fix/claude-paste-tail-hard-wrap

Conversation

@Juliusolsson05

@Juliusolsson05 Juliusolsson05 commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Problem

When a Claude prompt is paste-like but not collapsed into a placeholder, Agent Code proves it landed by finding the prompt's last 24 characters in the composer (pasteTailNeedle, pasteAbsorbedVia and pollPasteAbsorbed in src/shared/claude/pasteConfirm.ts). Claude's Ink wraps with hard: true (vendor/claude-code-src/full/ink/wrap-text.ts), so a token longer than the line (an absolute path, a URL) is cut mid-token. Collapsing whitespace turned that cut into a space the needle does not contain. The send hit its 5 s budget, rolled back, and left the prompt in the composer. This is #1113's experience on the text path.

Change

activeClaudeComposerText reconstructs the text the composer holds, undoing the TUI's wrapping. Claude draws its divider across the full width, and a composer line is one column shorter (measured on the recordings). A line that fills the composer width and whose continuation doesn't start with a space was cut mid-token, so the two join directly; every other break is a soft wrap and joins with a space. The tail is then a plain substring of that text, for both the baseline and every poll. With no divider on screen, every break is treated as soft (the old behaviour). A misread errs toward a timeout, never an early Enter.

Two earlier approaches were withdrawn in review: stripping all whitespace (it accepted missing whitespace) and a pattern with optional whitespace everywhere (it matched earlier text). Both guessed where the wrap was.

wrap-ansi becomes an explicit devDependency. The lockfile already had it hoisted with dev: true; only the root entry changes. The test loads it through a typed require.

Tests (fail-first)

  • Width sweep, 20–140 columns. The chrome comes from a recorded Claude Code 2.1.278 frame (testing/fixtures/image-absorption). The composer body is re-rendered at each width with Ink's own wrap options, for a prompt ending in …/promptDelivery.ts. The old code misses 20+ widths, including the issue's 46–50.
  • A half-landed paste stays unconfirmed.
  • foo\nbar does not confirm on a composer showing foobar, and does confirm on foo bar.
  • The recorded unrelated opencode [Image #1] composer does not confirm a pending opencode[\nImage#1].
  • A second paste over an already-present, hard-wrapped tail times out instead of confirming (baseline guard).

Mutation-checked, each individually: whitespace made optional, baseline guard removed, and baseline without wrap tolerance each turn a test red. Collapse instead of the pattern fails the sweep.

Review (2 Codex + 1 Pi)

Finding Verdict Disposition
A: stripping accepts missing whitespace. foo\nbar confirms on foobar, and a recorded unrelated image composer confirms a pending payload, so Enter is sent early valid, major fixed in 696578fa (asymmetric pattern), both are tests
B: the baseline transition is untested; dropping tailAlreadyPresent or reverting the baseline comparison survived valid, major fixed in 696578fa, test over a hard-wrapped pre-existing tail
tsc -b failed on an untyped wrap-ansi import valid fixed in 6ef099f5

| A round 2: optional whitespace between ANY characters matched an earlier part of the same paste (and read the recorded [Image / #1] as Image#1) | valid, major | fixed in 04db26dc: the composer text is reconstructed (a full line joins a mid-token cut directly, any other break is a soft wrap and joins with a space); both are tests |
| Pi F3: test geometry was pane − 2; the recordings are pane − 3 with ❯ + NBSP, so a hard-wrapped line is one column short of the divider, and the threshold would never have fired on a real screen | valid, major | fixed in 73df03a6: geometry calibrated on the recordings, threshold = divider − 1, and the test fails on the old threshold |
| Pi F1 (baseline guard untested), F2 (needle length unpinned) | valid, minor | F1 covered by the baseline test; F2 accepted (24 is a distinctiveness tuning, not a contract) |

Rounds: 2 (Codex A, Codex B, Pi). Round-2 findings were fixed by the author without a third round, per the at-most-two-rounds rule. Final state: B MERGE-READY, Pi MERGE-READY, A's round-2 finding fixed and tested.

Verification

npx tsc -b clean. src/shared/claude, TileLeaf and src/providers/claude pass.
Not verified here: a live Claude pane. Claude runs Bun.wrapAnsi, not the npm wrap-ansi; both implement hard.

Fixes #1118

🤖 Generated with Claude Code

Juliusolsson05 and others added 3 commits September 24, 2026 22:32
Claude's Ink wraps the composer with hard: true, so a token longer than
the line (an absolute path, a URL) is cut mid-token onto an indented
continuation line. The inline-tail detector collapsed whitespace, which
turned that cut into a space inside the path, so the tail needle never
matched: a paste-like prompt ending in a long path timed out after 5 s
and rolled back at 14 of 81 pane widths. The tail comparison now strips
whitespace on both sides, which is blind to where either kind of wrap
falls; the placeholder counters keep collapsing, since their regexes need
the spaces.

Test: the composer chrome of a recorded Claude 2.1.278 frame, re-rendered
at every width 20-140 with Ink's own wrap options. It fails on the old
code at 20+ widths (including the issue's 46-50), and a second case keeps
a half-landed paste unconfirmed.

Fixes #1118

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The width-sweep test imported wrap-ansi, a hoisted transitive dependency
with no types, so tsc -b failed. It is now a declared devDependency (the
lockfile's existing hoisted 7.0.0 entry, dev: true; only the root entry
changes) and is loaded through a typed require, since v7 is CommonJS with
no declarations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er missing whitespace

Review round 1 on #1219 (2 Codex reviewers; the Pi report is pending):

- Major (A): stripping every whitespace character also accepted MISSING
  whitespace, so a payload foo\nbar 'landed' on a composer showing
  foobar, and the recorded unrelated 'opencode [Image #1]' composer
  matched a pending opencode[\nImage#1] payload. Both sent Enter for a
  paste that had not landed. The tail is now matched by a pattern: each
  whitespace run in the (collapsed) needle must be whitespace on screen,
  and between any other two characters the screen MAY hold whitespace,
  which is exactly what a soft or hard wrap inserts.
- Major (B): the baseline transition had no test; dropping the
  tailAlreadyPresent guard or reverting the baseline to a plain substring
  survived. A second paste over an already-present hard-wrapped tail now
  times out instead of confirming.

Tests: both reviewer reproductions and the baseline case. Each fails under
its mutation (space made optional; guard removed; baseline without wrap
tolerance), and the 20-140 column sweep still passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Juliusolsson05 added a commit that referenced this pull request Sep 25, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Juliusolsson05 and others added 2 commits September 24, 2026 23:01
…t fell

Review round 2 (A) on #1219 found that the round-1 pattern, which allowed
optional whitespace between any two characters, matched an EARLIER part of
the same paste that had a real space where the tail has none, and so sent
Enter before the tail arrived. It also read the recorded soft-wrapped
'[Image' / '  #1]' pill as 'Image#1'.

Both withdrawn approaches guessed where a wrap might be. The composer text
is now reconstructed: Claude draws its chrome divider across the full
composer width, so a line that fills that width and whose continuation does
not start with a space was cut mid-token and joins with nothing between;
every other break was a soft wrap and joins with one space. The tail is
then a plain substring of that text, for both baseline and poll. With no
divider on screen every break is treated as soft (the old behaviour), and
misreading a rare exactly-full soft line errs toward a timeout, never an
early Enter.

Tests: both round-2 reproductions, plus the round-1 cases and the 20-140
column sweep, now through activeClaudeComposerText as production uses it.
Mutation-checked: never-hard, width ignored, physical baseline, and baseline
guard removed each turn a test red.

Final round: fixed by the author per the at-most-two-rounds rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Pi reviewer calibrated npm wrap-ansi against both recorded frames
(Claude Code 2.1.278, 64 columns): the composer body is pane - 3 columns,
the prompt marker is U+276F + NO-BREAK SPACE, so a hard-wrapped line is 63
wide, one column less than the 64-column divider. The reconstruction
compared against the divider width itself, so on a real screen it would
never have detected a hard cut; the tests passed only because their
synthetic geometry (pane - 2, plain space) did not match the recording.

The test now renders with the recorded geometry, and fails with the old
threshold (verified), and the threshold is divider width - 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Juliusolsson05 added a commit that referenced this pull request Sep 25, 2026
…ress

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Juliusolsson05
Juliusolsson05 merged commit 2721fe5 into main Sep 25, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the fix/claude-paste-tail-hard-wrap branch September 25, 2026 06:20
Juliusolsson05 added a commit that referenced this pull request Sep 25, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

bug(claude): the inline paste-tail needle is defeated by a hard wrap inside a long token

1 participant