diff --git a/src/session-auto-ops.ts b/src/session-auto-ops.ts index 74c3ee47..d2454c65 100644 --- a/src/session-auto-ops.ts +++ b/src/session-auto-ops.ts @@ -20,7 +20,7 @@ import { EventEmitter } from 'node:events'; import { detectUsageLimitPause } from './usage-limit-patterns.js'; -import { toSingleLine } from './utils/single-line.js'; +import { toSingleLine } from './utils/index.js'; // ============================================================================ // Timing Constants diff --git a/src/utils/single-line.ts b/src/utils/single-line.ts index 21cbc3db..e29b0bd5 100644 --- a/src/utils/single-line.ts +++ b/src/utils/single-line.ts @@ -5,16 +5,25 @@ */ /** - * A prompt as one line: every run of line breaks, with the whitespace around it, - * becomes a single space; a single-line prompt is returned as is. + * A prompt as one line: each line is trimmed, blank lines are dropped, and the rest + * are joined with a single space; a prompt without a line break is returned as is. * * For the senders that run unattended through `writeViaMux` (respawn's update and * kickstart steps, auto-compact). The mux pastes a multi-line prompt as one block, - * and the check that re-presses a stranded Enter cannot recognise a multi-line paste - * in the composer, so nobody would notice a prompt left unsubmitted. Cron rejects - * multi-line prompts for the same reason; these senders join them instead, so a - * saved multi-line setting is still sent rather than refused. + * and the check that re-presses a stranded Enter cannot reliably recognise a + * multi-line paste in the composer, so nobody would notice a prompt left unsubmitted. + * Cron rejects multi-line prompts for the same reason; these senders join them + * instead, so a saved multi-line setting is still sent rather than refused. + * + * Split and trim, which is linear, rather than one regex replace that folds the + * whitespace around each break: that regex backtracks quadratically over a long + * whitespace run (50,000 spaces took over a second). */ export function toSingleLine(prompt: string): string { - return prompt.replace(/\s*[\r\n]+\s*/g, ' '); + if (!/[\r\n]/.test(prompt)) return prompt; + return prompt + .split(/[\r\n]+/) + .map((line) => line.trim()) + .filter(Boolean) + .join(' '); } diff --git a/test/unattended-single-line-prompt.test.ts b/test/unattended-single-line-prompt.test.ts index 2ea1fb80..aa834174 100644 --- a/test/unattended-single-line-prompt.test.ts +++ b/test/unattended-single-line-prompt.test.ts @@ -5,9 +5,9 @@ * auto-compact focus prompt's schema accepts line breaks, so a saved config can hold * several lines. `TmuxManager.sendInput()` used to strip every `\n`, which ran the * lines together; it now pastes multi-line text as one block. These senders run - * unattended, and the check that re-presses a stranded Enter cannot recognise a - * multi-line paste in the composer, so they join the lines with a space before the - * write (`toSingleLine()`) instead of leaning on the mux to drop them. + * unattended, and the check that re-presses a stranded Enter cannot reliably + * recognise a multi-line paste in the composer, so they join the lines with a space + * before the write (`toSingleLine()`) instead of leaning on the mux to drop them. * * Strategy: drive the respawn step methods against a MockSession, and feed a real * Session a token count over its auto-compact threshold, then read what reached @@ -37,6 +37,22 @@ describe('toSingleLine', () => { it('returns a single-line prompt unchanged', () => { expect(toSingleLine(' keep "quotes" and $vars ')).toBe(' keep "quotes" and $vars '); }); + + it('drops leading and trailing blank lines instead of leaving a space there', () => { + expect(toSingleLine('\n compact the log \n\n')).toBe('compact the log'); + }); + + it('stays linear on a long whitespace run', () => { + // A regex that folds the whitespace around each break backtracks over every + // start position in the run: 50,000 spaces took well over a second that way. + // The split takes well under a millisecond, so the bound leaves room either side. + const run = ' '.repeat(50_000); + const started = performance.now(); + const joined = toSingleLine(`a${run}b\nc${run}`); + const elapsed = performance.now() - started; + expect(joined).toBe(`a${run}b c`); + expect(elapsed).toBeLessThan(250); + }); }); describe('respawn steps send a multi-line prompt as one line', () => {