From 8953d5b325e49dacd37b1d071695a9d8594bb897 Mon Sep 17 00:00:00 2001 From: Ronen Mars Date: Wed, 12 Aug 2026 17:18:56 +0300 Subject: [PATCH] fix(terminal): rejoin user prompts the PTY wrapped with an indent The streamer spawns the PTY at a fixed 120 columns, so Claude Code word-wraps a long prompt across rows and indents each continuation row. collapseWrappedUserLines joined those rows with a single space and compared the result to the user_message ground truth as an exact string, so the surviving indent made the match fail and the prompt rendered as two unrelated transcript lines. Compare on collapsed whitespace instead, and match the prompt prefix on the trimmed row so left padding on the row carrying the chevron does not stop the collapse before it starts. The collapsed row now emits the ground-truth string verbatim rather than the space-joined reconstruction, which also restores prompts that were typed with newlines. --- .../unit/lib/collapseWrappedUserLines.test.ts | 25 ++++++++++++++++ lib/collapseWrappedUserLines.ts | 30 ++++++++++++++----- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/__tests__/unit/lib/collapseWrappedUserLines.test.ts b/__tests__/unit/lib/collapseWrappedUserLines.test.ts index 8f1ea142..e30ff1c3 100644 --- a/__tests__/unit/lib/collapseWrappedUserLines.test.ts +++ b/__tests__/unit/lib/collapseWrappedUserLines.test.ts @@ -52,6 +52,31 @@ describe('collapseWrappedUserLines', () => { expect(collapseWrappedUserLines(lines, texts)).toEqual(lines) }) + it('collapses when the CLI indents the wrapped continuation row', () => { + const head = + 'In a new worktree change the Browse component files system Explorer to show not only directories, but files too, but' + const tail = "the files shouldn't be selectable/clickable, only for view." + const lines = [`❯ ${head}`, ` ${tail}`] + const texts = new Set([`${head} ${tail}`]) + expect(collapseWrappedUserLines(lines, texts)).toEqual([`❯ ${head} ${tail}`]) + }) + + it('collapses when the prompt row itself carries left padding', () => { + const lines = [' ❯ this is a long prompt that got', 'wrapped across rows'] + const texts = new Set(['this is a long prompt that got wrapped across rows']) + expect(collapseWrappedUserLines(lines, texts)).toEqual([ + '❯ this is a long prompt that got wrapped across rows', + ]) + }) + + it('restores the ground-truth text verbatim when the prompt had newlines', () => { + const lines = ['❯ first paragraph', 'second paragraph'] + const texts = new Set(['first paragraph\nsecond paragraph']) + expect(collapseWrappedUserLines(lines, texts)).toEqual([ + '❯ first paragraph\nsecond paragraph', + ]) + }) + it('collapses multiple separate wrapped prompts in the same line array', () => { const lines = [ '❯ first prompt part', diff --git a/lib/collapseWrappedUserLines.ts b/lib/collapseWrappedUserLines.ts index c2eeeb24..d3b9841e 100644 --- a/lib/collapseWrappedUserLines.ts +++ b/lib/collapseWrappedUserLines.ts @@ -1,14 +1,23 @@ const USER_PREFIX_RE = /^[❯›>]\s(.*)$/ const MAX_LOOKAHEAD = 20 +// The PTY does not round-trip whitespace: Claude Code indents wrapped +// continuation rows, and a prompt typed with newlines is echoed as separate +// rows. Compare on collapsed whitespace so neither turns an otherwise exact +// match into a miss. +function normalize(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} + /** * Collapses runs of PTY rows that together reconstruct one echoed user * prompt (wrapped by Claude Code's own CLI at its terminal width) into a * single row holding the ground-truth text from `userMessageTexts`. * - * Matching is exact-string only against `userMessageTexts` — no punctuation - * or sentence-boundary heuristics, since terminal output also contains - * prose, trees, and tables that a heuristic could misjoin. + * Matching is exact-string only against `userMessageTexts` (modulo + * whitespace) — no punctuation or sentence-boundary heuristics, since + * terminal output also contains prose, trees, and tables that a heuristic + * could misjoin. */ export function collapseWrappedUserLines( lines: string[], @@ -16,11 +25,16 @@ export function collapseWrappedUserLines( ): string[] { if (!userMessageTexts || userMessageTexts.size === 0) return lines + const groundTruthByNormalized = new Map() + for (const text of userMessageTexts) { + groundTruthByNormalized.set(normalize(text), text) + } + const result: string[] = [] let i = 0 while (i < lines.length) { - const match = lines[i].match(USER_PREFIX_RE) + const match = lines[i].trim().match(USER_PREFIX_RE) if (!match) { result.push(lines[i]) i += 1 @@ -28,23 +42,25 @@ export function collapseWrappedUserLines( } let accumulated = match[1] + let matched: string | undefined let matchedEnd = -1 const limit = Math.min(lines.length, i + 1 + MAX_LOOKAHEAD) for (let j = i; j < limit; j += 1) { if (j > i) accumulated += ' ' + lines[j] - if (userMessageTexts.has(accumulated.trim())) { + matched = groundTruthByNormalized.get(normalize(accumulated)) + if (matched !== undefined) { matchedEnd = j break } } - if (matchedEnd === -1) { + if (matchedEnd === -1 || matched === undefined) { result.push(lines[i]) i += 1 continue } - result.push(`❯ ${accumulated.trim()}`) + result.push(`❯ ${matched}`) i = matchedEnd + 1 }