Repository navigation
Description
What happens
A prompt that we classify as paste-like (input.includes('\n') || input.length > CLAUDE_PASTE_THRESHOLD, threshold 100) is bracketed-pasted, after which we wait for Claude's [Pasted text #N] placeholder and send the submit \r once it appears. But Claude only renders that placeholder when a paste crosses its inline→collapse threshold, which is far higher than 100. For everything in the band — roughly 100 chars up to Claude's collapse size, plus short multi-line pastes — Claude inlines the text, no placeholder ever renders, our detector times out, and we fire a blind \r. Under load that \r races Claude's paste accumulator and is swallowed: the prompt sits in the composer, Claude looks busy, no real turn starts, and the user has to press Enter a second time.
This is the still-open tail of #90, isolated to one concrete, reproducible case.
Evidence (from the ClaudePasteDetection dev module, #101 / PR #278)
With AGENT_CODE_DEV_DEBUG=1 and the module enabled on a Claude pane:
- Headless Snapshot Probe:
\[Pasted text #\d+→ NO MATCH against a live ~2.1k-char screen. The placeholder genuinely is not on screen. - Submit timeline: every row's
issued→detectedis—(i.e.placeholder:appearedfired for zero submits), and paste-like rows land onplaceholder:timeout→write:submit-cr { strategy: 'timeout-skip-timer' }. The wall-clock/blind fallback is firing on every paste, not as a rare fallback.
Root cause
packages/claude-code-headless/src/ClaudeCodeHeadless.ts:1258awaitPastePlaceholdermatches only/\[Pasted text #\d+/(:1267). No placeholder ⇒ guaranteed timeout for inlined pastes.src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts:187-196— on timeout we send\rblind (timeout-skip-timer). The code comment at:57-65already acknowledges this band ("pastes that DO NOT cross Claude's inline→collapse threshold … hit the timeout fallback every time") but treats the blind\ras "always safe by 500ms" — under load it is not.- Threshold mismatch: our paste-routing threshold (100) is unrelated to Claude's placeholder-collapse threshold, so a large band is routed to a detector that can never succeed for it.
Why our diagnostics don't catch it
We record that we sent the submit \r (write:submit-cr) and that the renderer's submit fn returned (OUTCOME:submit:returned), but nothing verifies that a turn actually started afterward. A swallowed \r is byte-identical in the journal to a successful one. The user-facing failure ("had to press Enter twice") produces no event.
Proposed fix
Stop depending on the placeholder as the sole timing signal; confirm the submit and retry the way a human does:
- Submit confirmation (main-side): after the submit
\r, poll for turn-start evidence within a short window (~300 ms) — the composer clearing (pasted text leaves the input line), spinner/activity appearing (isWorking()), or a semanticturn_started. Whichever first. - Bounded auto-retry: if not confirmed, resend
\r(up to ~2×). This automates the manual "press Enter again" workaround and is robust to whatever Claude's paste-state does — placeholder or not. - New diagnostic events so the glitch is finally visible:
submit:confirmed/submit:retry/submit:unconfirmed, surfaced as a column in theClaudePasteDetectiontimeline. - (Optional, complementary) make
awaitPastePlaceholderresolve on the inline case too (e.g. the bracketed-paste tail appearing in the composer), so the happy path doesn't always eat the 500 ms timeout.
This also dovetails with #90's own sketch (detect completion off an authoritative signal, not the screen regex) and #99 (semantic events alongside screen parsing).
Files
src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts(routing + submit timing)packages/claude-code-headless/src/ClaudeCodeHeadless.ts(awaitPastePlaceholder→ add a confirm/await-turn-start helper)src/renderer/src/features/debug/devModules/ClaudePasteDetection/(surface the confirm/retry outcome)
Activity
Merge pull request #280 from Juliusolsson05/fix/paste-submit-confirma…