mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
fix(desktop-terminal): stop idle prompt accumulation across relaunches
An idle terminal tab (no command ever typed) grew one extra copy of the shell's boot prompt on every close/reopen: persistSnapshot re-serialized a buffer that was just the replayed old prompt plus the fresh shell's new prompt, and cleanReviveSnapshot's blank-line trim can't strip prompts on shells like default PowerShell that print no separator line. Track real user input (keystrokes/paste, drag-and-drop paths, injected commands) and, when a session had none, skip re-serializing. If the buffer we loaded carried no real scrollback (empty or only a repeated prompt), clear it so the next launch shows a single fresh prompt and any existing accumulation heals; otherwise leave the prior snapshot untouched so real history from an earlier active session survives an idle reopen. Salvages #61584 (activity tracking) and #61577 (clearing content-free idle buffers) into one path: it also heals already-polluted buffers, counts drag-and-drop and injected input as activity, and never discards genuine short command history (only empty/all-identical buffers are cleared). Co-authored-by: alelpoan <alelpoan@proton.me> Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
This commit is contained in:
parent
964ecef401
commit
164d9126cc
2 changed files with 96 additions and 1 deletions
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { cleanReviveSnapshot, isIdlePromptOnly } from './use-terminal-session'
|
||||
|
||||
// A default-PowerShell idle prompt: no blank-line separator before it.
|
||||
const PS_PROMPT = 'PS C:\\Users\\Aleksandr>'
|
||||
|
||||
describe('isIdlePromptOnly', () => {
|
||||
it('is true for an empty or whitespace-only buffer', () => {
|
||||
expect(isIdlePromptOnly('')).toBe(true)
|
||||
expect(isIdlePromptOnly('\r\n \r\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('is true for a lone prompt line', () => {
|
||||
expect(isIdlePromptOnly(PS_PROMPT)).toBe(true)
|
||||
})
|
||||
|
||||
it('is true for a buffer that is only repeated identical prompts (accumulation)', () => {
|
||||
expect(isIdlePromptOnly([PS_PROMPT, PS_PROMPT, PS_PROMPT].join('\r\n'))).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores blank gaps between repeated prompts (the "gapped" variant)', () => {
|
||||
expect(isIdlePromptOnly([PS_PROMPT, '', '', PS_PROMPT].join('\r\n'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is false when the buffer holds a real command and output', () => {
|
||||
expect(isIdlePromptOnly([PS_PROMPT, 'cd project', 'PS C:\\Users\\Aleksandr\\project>'].join('\r\n'))).toBe(false)
|
||||
})
|
||||
|
||||
it('is false when two different prompts are present (cwd actually changed)', () => {
|
||||
expect(isIdlePromptOnly([PS_PROMPT, 'PS C:\\Users\\Aleksandr\\project>'].join('\r\n'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanReviveSnapshot', () => {
|
||||
it('drops a short trailing prompt block after a blank separator', () => {
|
||||
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')
|
||||
|
||||
expect(cleanReviveSnapshot(snapshot)).toBe(['start', '', ...tail].join('\r\n'))
|
||||
})
|
||||
|
||||
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.
|
||||
const snapshot = ['echo hi', 'hi', PS_PROMPT].join('\r\n')
|
||||
|
||||
expect(cleanReviveSnapshot(snapshot)).toBe(snapshot)
|
||||
})
|
||||
})
|
||||
|
|
@ -168,13 +168,17 @@ function stripInitialPromptGap(data: string) {
|
|||
return prefix
|
||||
}
|
||||
|
||||
// A row's content with ANSI escapes and all whitespace stripped — '' for a
|
||||
// spacer / prompt-gap / zsh `%` marker row.
|
||||
const visibleText = (line: string) => stripEscapeSequences(line).replace(/[\s%]/g, '')
|
||||
|
||||
// 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).
|
||||
function cleanReviveSnapshot(serialized: string): string {
|
||||
export function cleanReviveSnapshot(serialized: string): string {
|
||||
const visible = (line: string) => stripEscapeSequences(line).replace(/[\s%]/g, '')
|
||||
const lines = serialized.split(/\r?\n/)
|
||||
|
||||
|
|
@ -200,6 +204,17 @@ function cleanReviveSnapshot(serialized: string): string {
|
|||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
// True when a revive buffer holds no real scrollback: empty, or only repeats of
|
||||
// one line (the idle prompt). This is the idle-accumulation signature (#61572) —
|
||||
// each relaunch replayed the saved prompt(s) and the fresh shell printed one more
|
||||
// below, growing the tab by a line per cycle. Real sessions vary (prompt +
|
||||
// command + output), so genuine short histories are never mistaken for idle.
|
||||
export function isIdlePromptOnly(serialized: string): boolean {
|
||||
const lines = serialized.split(/\r?\n/).map(visibleText).filter(Boolean)
|
||||
|
||||
return lines.length === 0 || lines.every(line => line === lines[0])
|
||||
}
|
||||
|
||||
interface UseTerminalSessionOptions {
|
||||
/** Renderer-side terminal id (the tab handle), used to key the agent reader. */
|
||||
id: string
|
||||
|
|
@ -336,6 +351,10 @@ export function useTerminalSession({
|
|||
// Snapshot the revive buffer once: live snapshots feed updateTerminalReviveBuffer
|
||||
// and would otherwise re-arm replay on every store-driven re-render.
|
||||
const initialReviveBufferRef = useRef(reviveBuffer)
|
||||
// Whether the user ever fed input into this session (keystrokes, paste,
|
||||
// drag-and-drop paths, or an injected command). Gates idle-buffer handling in
|
||||
// persistSnapshot so an untouched tab never re-saves an accumulating snapshot.
|
||||
const hasSessionActivityRef = useRef(false)
|
||||
const shellNameRef = useRef('shell')
|
||||
const selectionLabelRef = useRef('')
|
||||
const selectionRef = useRef('')
|
||||
|
|
@ -486,6 +505,22 @@ export function useTerminalSession({
|
|||
|
||||
lastSnapshotAt = Date.now()
|
||||
|
||||
// No user input this session: never re-serialize. The live buffer now holds
|
||||
// the replayed history plus a fresh boot prompt, and re-saving that is
|
||||
// exactly what grew idle tabs by one prompt line per relaunch (#61572).
|
||||
// If the buffer we loaded carried no real scrollback (empty, or only a
|
||||
// repeated prompt), clear it so the next launch shows a single fresh prompt
|
||||
// and any pre-existing accumulation heals. Otherwise leave the prior
|
||||
// snapshot untouched so real history from an earlier active session
|
||||
// survives an idle reopen instead of being overwritten.
|
||||
if (!hasSessionActivityRef.current) {
|
||||
if (isIdlePromptOnly(initialReviveBufferRef.current ?? '')) {
|
||||
updateTerminalReviveBuffer(id, '')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = serialize.serialize({ scrollback: PERSISTENT_SESSION_SCROLLBACK })
|
||||
updateTerminalReviveBuffer(id, cleanReviveSnapshot(snapshot))
|
||||
|
|
@ -544,6 +579,7 @@ export function useTerminalSession({
|
|||
return
|
||||
}
|
||||
|
||||
hasSessionActivityRef.current = true
|
||||
void terminalApi.write(id, `${paths.map(p => quotePathForShell(p, shellNameRef.current)).join(' ')} `)
|
||||
term.focus()
|
||||
triggerHaptic('selection')
|
||||
|
|
@ -640,6 +676,7 @@ export function useTerminalSession({
|
|||
})
|
||||
|
||||
const dataDisposable = term.onData(data => {
|
||||
hasSessionActivityRef.current = true
|
||||
const id = sessionIdRef.current
|
||||
|
||||
if (id) {
|
||||
|
|
@ -846,6 +883,7 @@ export function useTerminalSession({
|
|||
return
|
||||
}
|
||||
|
||||
hasSessionActivityRef.current = true
|
||||
void window.hermesDesktop?.terminal?.write(sessionId, `${command}\r`)
|
||||
$terminalInjection.set(null)
|
||||
termRef.current?.focus()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue