fix(desktop-terminal): trim trailing idle prompt on no-separator shells

cleanReviveSnapshot only dropped the trailing prompt when a blank separator
sat above it (starship add_newline), so shells that print the prompt with no
preceding blank line — default PowerShell (PS C:\..>), bash user@host:~$ —
kept the idle prompt in the saved buffer and showed a duplicate under the
fresh boot prompt on every relaunch of an *active* session.

An interactive shell always reprints its prompt after a command, so the tail
of an idle buffer is the prompt, never history. Drop the short block after a
blank separator when present, otherwise drop the trailing single-line prompt.
Command output is preserved; the fresh shell reprints the live prompt on boot.
This commit is contained in:
Brooklyn Nicholson 2026-07-12 02:39:45 -04:00
parent 164d9126cc
commit 817969f5a2
2 changed files with 45 additions and 28 deletions

View file

@ -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('')
})
})

View file

@ -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')
}