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 }