diff --git a/src/renderer/src/features/debug/devModules/ClaudePasteDetection/module.tsx b/src/renderer/src/features/debug/devModules/ClaudePasteDetection/module.tsx index e4a359fe7..9655d074c 100644 --- a/src/renderer/src/features/debug/devModules/ClaudePasteDetection/module.tsx +++ b/src/renderer/src/features/debug/devModules/ClaudePasteDetection/module.tsx @@ -89,6 +89,24 @@ function ClaudePasteDetection({ sessionId, runtime, kind }: DevDebugModuleProps)
+ {/* Pane 0 — live screen copy. Reads runtime.screen every render, so it + stays in lock-step with the headless TUI: paste, and you watch the + composer fill (placeholder or inlined text) right here. This is the + exact snapshot the content-match submit detector keys on. */} +
+
+ + screen copy (live) + + + {plain.length} chars · {(plain.match(/\[Pasted text #\d+/g) ?? []).length} placeholders + +
+
+            {plain.slice(-600) || '(screen empty)'}
+          
+
+ {/* Pane 1 — live detection */}
live detection
@@ -134,6 +152,7 @@ function ClaudePasteDetection({ sessionId, runtime, kind }: DevDebugModuleProps) issued→det det→cr outcome + via len strategy @@ -141,7 +160,7 @@ function ClaudePasteDetection({ sessionId, runtime, kind }: DevDebugModuleProps) {rows.length === 0 ? ( - + no submits recorded yet @@ -162,6 +181,17 @@ function ClaudePasteDetection({ sessionId, runtime, kind }: DevDebugModuleProps) > {row.outcome} + + {row.via ?? '—'} + {row.composerLen ?? '—'} {row.strategy ?? '—'} diff --git a/src/renderer/src/features/debug/devModules/ClaudePasteDetection/timeline.ts b/src/renderer/src/features/debug/devModules/ClaudePasteDetection/timeline.ts index 8279cfe34..b1a4368d1 100644 --- a/src/renderer/src/features/debug/devModules/ClaudePasteDetection/timeline.ts +++ b/src/renderer/src/features/debug/devModules/ClaudePasteDetection/timeline.ts @@ -34,6 +34,9 @@ export type SubmitLifecycle = { outcome: SubmitOutcome composerLen: number | null strategy: string | null + /** Which absorption signal fired: 'placeholder' (collapsed paste) or + * 'inline' (medium paste, the #279 case) — null if it timed out. */ + via: string | null } function find(events: PasteDebugEvent[], layer: string, event: string): PasteDebugEvent | undefined { @@ -49,9 +52,14 @@ export function buildLifecycle(session: PasteDebugSession): SubmitLifecycle { const ev = session.events const enter = find(ev, 'RENDER', 'keydown:enter') - const appeared = find(ev, 'SCREEN', 'placeholder:appeared') + // Content-match events (#279 fix) with back-compat to the older + // placeholder-only event names so historical journals still read. + const appeared = + find(ev, 'SCREEN', 'paste:absorbed') ?? find(ev, 'SCREEN', 'placeholder:appeared') const timedOut = - find(ev, 'SCREEN', 'placeholder:timeout') ?? find(ev, 'SCREEN', 'placeholder:no-session') + find(ev, 'SCREEN', 'paste:absorb-timeout') ?? + find(ev, 'SCREEN', 'placeholder:timeout') ?? + find(ev, 'SCREEN', 'placeholder:no-session') const submitCr = find(ev, 'IPC', 'write:submit-cr') const singleWrite = find(ev, 'IPC', 'write:paste-and-submit-single') const threw = ev.find(e => e.layer === 'ERROR') @@ -82,6 +90,7 @@ export function buildLifecycle(session: PasteDebugSession): SubmitLifecycle { outcome, composerLen: num(enter?.data, 'composerLen'), strategy, + via: typeof appeared?.data?.via === 'string' ? appeared.data.via : null, } } diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts b/src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts index b3330c7cf..7a715617b 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/claudePaste.ts @@ -1,11 +1,12 @@ // Claude's TUI has its own paste-state machine. Large input and // bracketed paste do NOT go straight from "bytes arrived" to "submit -// immediately" — Claude first buffers the paste, may collapse it into -// a `[Pasted text #N]` placeholder in the visible composer, and only -// later expands it back out during submit. If we send the paste -// payload AND the trailing Enter in the same PTY write, that Enter -// can land while Claude still considers the paste "in flight". The -// result is the exact user-facing bug we saw: +// immediately" — Claude buffers every `isPasted` chunk and flushes on a +// ~100ms debounce (`PASTE_COMPLETION_TIMEOUT_MS` in claude-code-src). +// INSIDE that debounce window a `pastePendingRef` guard swallows every +// subsequent keystroke — INCLUDING our submit `\r` — as more paste +// content. So if the Enter lands before the debounce fires, it becomes +// literal text, no turn starts, and the user has to press Enter again. +// That is the exact user-facing bug: // // 1. the pasted prompt appears in Claude's composer, // 2. Claude flips into a "working" looking state, @@ -13,61 +14,53 @@ // 4. and the NEXT Enter finally submits the already-buffered prompt. // // We fix this at the shell boundary instead of forking Claude's paste -// logic: for Claude only, any prompt that is likely to be treated as -// a paste gets delivered in TWO phases — first the payload, then a -// separate submit write after Claude visibly acknowledges the paste. +// logic: deliver the paste in TWO phases — first the bracketed payload, +// then a separate submit `\r` only AFTER we can see Claude's composer +// has visibly absorbed the paste (the debounce has provably fired). // -// The primary strategy is event-driven and not user-configurable: +// ---------------------------------------------------------------------------- +// THE SIGNAL IS A CONTENT MATCH, NOT A CLOCK. +// ---------------------------------------------------------------------------- // -// * EVENT-DRIVEN: poll Claude's screen snapshot for -// `[Pasted text #N]` and send `\r` the instant the placeholder -// appears. Load-independent — works at 10 ms or 10 s. This is -// the strategy the paste-submit repro harness at -// `vendor/in_progress/paste-submit-repro/` showed to be 10/10 -// reliable with average waits ~58 ms. +// We compare what the user submitted against what is actually in the +// headless composer, via the live screen snapshot (`latestScreenRef`), +// and send `\r` the instant the composer reflects the paste. Two +// screen-truth manifestations, because Claude renders pastes two ways: // -// * WALL-CLOCK FALLBACK: wait `delayMs` (default 125 ms — slightly -// above Claude's 100 ms paste completion timeout) then send `\r`. -// Kept only as a fallback because the event-driven path depends on -// Claude continuing to render the placeholder. A future Claude UI -// rename should degrade paste-submit, not brick it entirely. The -// harness shows the timer races even at 1000 ms under load, so it -// must never be exposed as a selectable primary path again. +// * COLLAPSE: a large paste becomes a `[Pasted text #N]` placeholder. +// Signal: a NEW placeholder appeared vs. the pre-paste baseline. +// * INLINE: a medium paste (≈100 chars up to Claude's collapse +// threshold) is inserted as raw text. NO placeholder ever renders — +// this is the band that issue #279 / #90 kept failing on, because +// the old detector only watched for the placeholder, timed out, and +// blind-fired `\r` straight into the debounce. Signal: the paste's +// tail newly appears in the composer. +// +// Both are load-independent: 10ms or 10s, we wait for the *condition*. +// +// The wall-clock wait survives ONLY as a rare safety floor — if neither +// signal materializes within a short bound (a future Claude UI rename, +// or a screen snapshot we can't read), we send `\r` anyway so submit +// degrades rather than hangs. It must never again be the primary path: +// real production traces show the timer races even at 1000ms under load, +// and "there's no value of T that's correct under all load conditions." // // Why these numbers: -// - 100 chars: empirical lower bound on text length where Claude's -// TUI runs its internal paste accumulator. The original constant -// was 800 because we believed it had to match Claude's *collapse* -// threshold (the size at which Claude renders `[Pasted text #N]` -// instead of inlining the bytes). It does not. Claude's paste -// ACCUMULATOR triggers independently, around ~100 chars, regardless -// of whether the inlined-vs-collapse cutoff has been crossed. Real -// per-paste debug dumps showed the bug recurring on 145 / 156 / 177 -// / 215-char single-line text that we were sending as -// `route:claude-plain-text` (raw text + `\r` in one PTY write), -// because Claude's accumulator engaged on the inline bytes and -// swallowed the trailing `\r`. Lowering this to 100 routes those -// cases through the bracketed-paste + event-driven path instead, -// which is the bug fix. -// - 125 ms is slightly above Claude's 100 ms paste completion -// timeout. Going lower risks reintroducing the race; going much -// higher makes submit feel laggy on every long paste. Only -// consulted only when the event-driven path times out or cannot -// reach the live Claude session. -// - 500 ms event-driven safety bound (down from 2000 ms): for -// bracketed pastes that DO NOT cross Claude's inline→collapse -// threshold, the `[Pasted text #N]` placeholder never renders, so -// our detector hits the timeout fallback every time. At 2000 ms -// that became a noticeable per-submit lag on any text 100–800 -// chars — exactly the size range this PR moves into the -// bracketed-paste path. 500 ms is well past Claude's 100 ms -// accumulator window (so `\r` is always safe by then) and short -// enough to feel responsive even on the slow-path fallback. +// - 100 chars: empirical lower bound where Claude's paste ACCUMULATOR +// engages, independent of the inline-vs-collapse cutoff. Below this we +// send raw `text + \r` in one write (the accumulator doesn't engage, +// so the `\r` is safe). +// - 125 ms floor: slightly above Claude's 100 ms debounce. Consulted +// ONLY when the content match can't be made (the rare safety net). +// - 500 ms detection bound: well past the 100 ms accumulator window, so +// a real absorption always wins first; short enough that the rare +// safety-net path still feels responsive. export const CLAUDE_PASTE_THRESHOLD = 100 export const CLAUDE_PASTE_SUBMIT_DELAY_MS = 125 export const CLAUDE_IMAGE_PATH_SUBMIT_DELAY_MS = 750 export const CLAUDE_PASTE_EVENT_DRIVEN_TIMEOUT_MS = 500 +export const CLAUDE_PASTE_POLL_INTERVAL_MS = 10 function wait(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) @@ -86,6 +79,81 @@ async function sha8(data: string): Promise { return hex.join('') } +// --- Content-match detection helpers --------------------------------------- + +const PASTE_PLACEHOLDER_RE = /\[Pasted text #\d+/g + +export function placeholderCount(screen: string): number { + const matches = screen.match(PASTE_PLACEHOLDER_RE) + return matches ? matches.length : 0 +} + +function normalizeWhitespace(s: string): string { + return s.replace(/\s+/g, ' ') +} + +// A distinctive needle from the END of the paste. The paste's tail lands at +// the composer cursor, so it's the most reliable contiguous substring to find +// when Claude inlines a medium paste (no placeholder). Whitespace-normalized +// so the TUI's reflow/wrapping doesn't defeat the match. Null for pastes too +// short to fingerprint distinctively — those never reach this path (they take +// the plain `text + \r` route). +export function pasteTailNeedle(payload: string): string | null { + const norm = normalizeWhitespace(payload).trim() + return norm.length >= 8 ? norm.slice(-24) : null +} + +export type PasteAbsorbedOutcome = + | { kind: 'absorbed'; waitedMs: number; via: 'placeholder' | 'inline' } + | { kind: 'timeout' } + +/** + * Resolve when Claude's composer visibly reflects the paste — i.e. the ~100ms + * paste-accumulator debounce has fired and `\r` can no longer be swallowed. + * + * `getScreen` returns the live headless screen snapshot (the renderer's + * `latestScreenRef`, kept in sync by `onSessionScreen`). `baselineScreen` is + * captured BEFORE the bracketed paste so we detect the *transition* (a NEW + * placeholder / the tail NEWLY appearing) rather than a coincidental match + * against content that was already on screen (e.g. the same text in scrollback). + */ +export function waitForPasteAbsorbed( + getScreen: () => string | undefined, + baselineScreen: string, + payload: string, + opts: { timeoutMs: number; pollIntervalMs: number }, +): Promise { + const tail = pasteTailNeedle(payload) + const baseCount = placeholderCount(baselineScreen) + const tailAlreadyPresent = tail + ? normalizeWhitespace(baselineScreen).includes(tail) + : false + const startedAt = Date.now() + return new Promise(resolve => { + const tick = (): void => { + const screen = getScreen() ?? '' + // Collapse case: a NEW `[Pasted text #N]` placeholder appeared. + if (placeholderCount(screen) > baseCount) { + resolve({ kind: 'absorbed', waitedMs: Date.now() - startedAt, via: 'placeholder' }) + return + } + // Inline case: the paste's tail newly appears in the composer. The + // `!tailAlreadyPresent` guard keeps a duplicate in scrollback from + // false-confirming before the paste has actually landed. + if (tail && !tailAlreadyPresent && normalizeWhitespace(screen).includes(tail)) { + resolve({ kind: 'absorbed', waitedMs: Date.now() - startedAt, via: 'inline' }) + return + } + if (Date.now() - startedAt >= opts.timeoutMs) { + resolve({ kind: 'timeout' }) + return + } + setTimeout(tick, opts.pollIntervalMs) + } + tick() + }) +} + export type ClaudePasteSendFn = (data: string, pasteId?: string) => Promise export type ClaudePasteOpts = { @@ -93,11 +161,11 @@ export type ClaudePasteOpts = { * every PTY write in this paste emits an IPC:write event to the * journal. */ pasteId?: string - /** Event-driven submit for Claude paste-like text. The composer always - * provides this for Claude; optional in the type only because lower-level - * helpers are also used by Codex and image-path paste flows that must not - * wait for Claude's text placeholder. */ - eventDriven?: { enabled: boolean; sessionId: string } + /** Event-driven submit for Claude paste-like text. `getScreen` returns the + * live headless TUI snapshot so we can confirm the composer absorbed the + * paste before sending Enter. Optional in the type only because lower-level + * helpers are also used by Codex and image-path flows that do not need it. */ + eventDriven?: { enabled: boolean; getScreen: () => string | undefined } } export async function sendBracketedPaste( @@ -125,13 +193,11 @@ export async function sendBracketedPasteThenSubmit( const pasteId = opts?.pasteId const eventDriven = opts?.eventDriven - // Single-write fast path. Used by callers that don't care about - // the placeholder race (e.g. Codex, which has its own paste-state - // machine that does NOT exhibit this bug). Even on Claude we honor - // this when explicitly opted in via `delayMs <= 0` — but the - // composer call site in useComposerKeybinds always sets a positive - // delay AND sets `eventDriven` so this branch is effectively - // codex-only in production. + // Single-write fast path. Used by callers that don't care about the + // accumulator race (e.g. Codex, which has its own paste-state machine that + // does NOT exhibit this bug). On Claude this branch is only taken when + // `delayMs <= 0` AND event-driven is off — the composer call site always + // sets event-driven, so in production this is codex-only. if (delayMs <= 0 && !eventDriven?.enabled) { if (pasteId) { window.api.recordPasteDebugEvent(pasteId, { @@ -144,59 +210,40 @@ export async function sendBracketedPasteThenSubmit( return } + // Capture the composer baseline BEFORE writing the paste so the detector + // keys on the placeholder/inline-text *transition*, not a stale match. + const baselineScreen = eventDriven?.enabled ? (eventDriven.getScreen() ?? '') : '' + await sendBracketedPaste(send, payload, opts) - // Try the event-driven path first if enabled. The placeholder poll - // happens main-side inside `awaitClaudePastePlaceholder` so we - // don't pay an IPC round-trip per poll tick (10 ms cadence × 100s - // of ms wait would be ~30 IPC msgs/paste otherwise). if (eventDriven?.enabled) { - const outcome = await window.api.awaitClaudePastePlaceholder( - eventDriven.sessionId, - { timeoutMs: CLAUDE_PASTE_EVENT_DRIVEN_TIMEOUT_MS }, - ) + const outcome = await waitForPasteAbsorbed(eventDriven.getScreen, baselineScreen, payload, { + timeoutMs: CLAUDE_PASTE_EVENT_DRIVEN_TIMEOUT_MS, + pollIntervalMs: CLAUDE_PASTE_POLL_INTERVAL_MS, + }) if (pasteId) { window.api.recordPasteDebugEvent(pasteId, { layer: 'SCREEN', - event: outcome.kind === 'appeared' - ? 'placeholder:appeared' - : outcome.kind === 'timeout' - ? 'placeholder:timeout' - : 'placeholder:no-session', - data: outcome.kind === 'appeared' ? { waitedMs: outcome.waitedMs } : {}, + event: outcome.kind === 'absorbed' ? 'paste:absorbed' : 'paste:absorb-timeout', + data: + outcome.kind === 'absorbed' + ? { waitedMs: outcome.waitedMs, via: outcome.via } + : {}, }) } - if (outcome.kind === 'appeared') { - if (pasteId) { - window.api.recordPasteDebugEvent(pasteId, { - layer: 'IPC', - event: 'write:submit-cr', - data: { strategy: 'event-driven', waitedMs: outcome.waitedMs }, - }) - } - await send('\r', pasteId) - return - } - // Fall through to the wall-clock path. We've already waited some - // amount (up to `timeoutMs`) so subtract that from `delayMs` — - // otherwise the timeout + delayMs add up to noticeable lag. - // Floor at 0 in case the event-driven wait already exceeded delayMs. - // We DON'T have the actual elapsed in the no-session/timeout - // outcomes, but the timeout case took close to `timeoutMs`, so - // skip the timer entirely there. - if (outcome.kind === 'timeout') { + if (outcome.kind === 'absorbed') { if (pasteId) { window.api.recordPasteDebugEvent(pasteId, { layer: 'IPC', event: 'write:submit-cr', - data: { strategy: 'timeout-skip-timer' }, + data: { strategy: `event-driven:${outcome.via}`, waitedMs: outcome.waitedMs }, }) } await send('\r', pasteId) return } - // no-session: the renderer thinks we have a session but main - // doesn't. Skip directly to the wall-clock path as a last-ditch. + // Absorption not detected within the safety bound. Fall through to the + // wall-clock floor — the rare safety net, NOT the primary path. } if (delayMs > 0) await wait(delayMs) @@ -204,7 +251,7 @@ export async function sendBracketedPasteThenSubmit( window.api.recordPasteDebugEvent(pasteId, { layer: 'IPC', event: 'write:submit-cr', - data: { strategy: 'wall-clock-timer', delayMs }, + data: { strategy: 'wall-clock-floor', delayMs }, }) } await send('\r', pasteId) diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts b/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts index d3f0ce8da..9086f1a17 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts @@ -239,7 +239,13 @@ export function useComposerKeybinds({ }) await sendBracketedPasteThenSubmit(send, input, CLAUDE_PASTE_SUBMIT_DELAY_MS, { pasteId, - eventDriven: { enabled: true, sessionId }, + // Content-match submit: confirm Claude's composer actually shows the + // paste (placeholder OR inlined text) before sending Enter, via the + // live screen snapshot. No clock as the primary path. See #279 / #90. + eventDriven: { + enabled: true, + getScreen: () => workspace.latestScreenRef.current[sessionId], + }, }) } else { window.api.recordPasteDebugEvent(pasteId, {