diff --git a/apps/desktop/src/app/right-sidebar/terminal/revive-buffer.test.ts b/apps/desktop/src/app/right-sidebar/terminal/revive-buffer.test.ts index 73c7052b9ec..aee88ec30e5 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/revive-buffer.test.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/revive-buffer.test.ts @@ -33,25 +33,40 @@ describe('isIdlePromptOnly', () => { }) describe('cleanReviveSnapshot', () => { - it('drops a short trailing prompt block after a blank separator', () => { + it('drops a spaced trailing prompt block after a blank separator (starship)', () => { const snapshot = ['echo hi', 'hi', '', PS_PROMPT].join('\r\n') expect(cleanReviveSnapshot(snapshot)).toBe('echo hi\r\nhi') }) - it('keeps real output when the tail after the blank line is long', () => { - const tail = ['line1', 'line2', 'line3', 'line4', 'line5'] - const snapshot = ['start', '', ...tail].join('\r\n') + it('drops a multi-line prompt block after a blank separator (powerline)', () => { + const snapshot = ['work', '', '┌─ user@host ~/project', '└─$'].join('\r\n') - expect(cleanReviveSnapshot(snapshot)).toBe(['start', '', ...tail].join('\r\n')) + expect(cleanReviveSnapshot(snapshot)).toBe('work') }) - it('leaves a prompt with no preceding blank line untouched (heuristic limitation)', () => { - // Default PowerShell prints no blank line before its prompt, so the trailing - // prompt survives here — the idle-accumulation path (isIdlePromptOnly) is what - // actually covers that shell, not this trimmer. + it('drops a single-line trailing prompt with no preceding blank line (PowerShell)', () => { + // Default PowerShell prints no blank line before its prompt; the fresh shell + // reprints it on boot, so the redundant idle prompt must be trimmed here. const snapshot = ['echo hi', 'hi', PS_PROMPT].join('\r\n') - expect(cleanReviveSnapshot(snapshot)).toBe(snapshot) + expect(cleanReviveSnapshot(snapshot)).toBe('echo hi\r\nhi') + }) + + it('keeps command output and drops only the trailing prompt on a long history', () => { + const history = ['cmd1', 'out1', 'cmd2', 'out2'] + const snapshot = [...history, PS_PROMPT].join('\r\n') + + expect(cleanReviveSnapshot(snapshot)).toBe(history.join('\r\n')) + }) + + it('reduces a lone prompt to an empty buffer', () => { + expect(cleanReviveSnapshot(PS_PROMPT)).toBe('') + expect(cleanReviveSnapshot([PS_PROMPT, '', ''].join('\r\n'))).toBe('') + }) + + it('returns empty for a blank-only buffer without throwing', () => { + expect(cleanReviveSnapshot('')).toBe('') + expect(cleanReviveSnapshot('\r\n \r\n')).toBe('') }) }) diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index 6112fe70a19..e1c77baaf00 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -174,32 +174,34 @@ const visibleText = (line: string) => stripEscapeSequences(line).replace(/[\s%]/ // Trim the shell's trailing idle prompt from a serialized snapshot before it's // persisted. Without it, the saved buffer ends in the old prompt, so the next -// launch replays it directly above the fresh shell's prompt ("double bar"). The -// prompt is the short block after the last blank line (starship's add_newline -// gap); only a short tail is dropped, so real command output is never trimmed and -// configs without that blank line simply keep the historical prompt (no loss). +// launch replays it directly above the fresh shell's prompt ("double bar"). +// +// An interactive shell always reprints its prompt after a command finishes, so +// the tail of an idle buffer is the prompt, never real history. Two prompt +// shapes exist: +// - Spaced/multi-line (starship add_newline, powerline): a blank line sits +// just above the prompt, so the short block after the last blank is dropped. +// - Single-line (default PowerShell `PS C:\..>`, bash `user@host:~$`): no blank +// separator, so the final line itself is the prompt and is dropped. +// The fresh shell reprints the current prompt on boot either way, so only the +// redundant idle prompt is removed — command output is preserved. export function cleanReviveSnapshot(serialized: string): string { - const visible = (line: string) => stripEscapeSequences(line).replace(/[\s%]/g, '') const lines = serialized.split(/\r?\n/) - while (lines.length && visible(lines[lines.length - 1]) === '') { + while (lines.length && !visibleText(lines[lines.length - 1])) { lines.pop() } - let lastBlank = -1 - - for (let i = lines.length - 1; i >= 0; i -= 1) { - if (visible(lines[i]) === '') { - lastBlank = i - - break - } + if (lines.length === 0) { + return '' } - // A prompt is a short block; a long tail after the blank is real output, leave it. - if (lastBlank >= 0 && lines.length - 1 - lastBlank <= 3) { - lines.length = lastBlank - } + const lastBlank = lines.findLastIndex(line => !visibleText(line)) + const spacedPrompt = lastBlank >= 0 && lines.length - 1 - lastBlank <= 3 + + // Spaced prompt (starship/powerline): drop the block after the blank + // separator. Otherwise the last line is the single-line prompt itself. + lines.length = spacedPrompt ? lastBlank : lines.length - 1 return lines.join('\r\n') }