Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions __tests__/unit/lib/collapseWrappedUserLines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
30 changes: 23 additions & 7 deletions lib/collapseWrappedUserLines.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,66 @@
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[],
userMessageTexts: Set<string> | undefined,
): string[] {
if (!userMessageTexts || userMessageTexts.size === 0) return lines

const groundTruthByNormalized = new Map<string, string>()
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
continue
}

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
}

Expand Down
Loading