fix(terminal): stop garbling logs after a search filter is cleared

The terminal wrote incrementally by diffing on array length alone: if the
new logs were at least as long as what had been written, it appended the
tail from the old length onward. That assumes the on-screen prefix never
changes — but the same component is fed filtered subsets, so narrowing a
search then clearing it grew the array back past the filtered length and
appended the full-log tail on top of the still-visible filtered lines,
producing a scrambled, partly-duplicated buffer.

Track the exact lines last rendered and only take the append fast-path when
they are a true prefix of the new array; otherwise clear and rewrite. Pure
streaming stays incremental (no clear); any non-prefix change rewrites.

Verified with a repro test (red before, green after): full -> filtered ->
cleared restores the full set, streaming appends never clear, and a
different filter rewrites. 996 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-18 11:55:28 +07:00
co-authored by Claude Opus 4.8
parent cf68f3a675
commit 140578fd7c
2 changed files with 102 additions and 13 deletions
@@ -0,0 +1,86 @@
import { render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
const xterm = vi.hoisted(() => {
const state = {
clear() {
state.clearCount += 1;
state.visible = '';
},
clearCount: 0,
scrollToBottom() {},
visible: '',
write(chunk: string) {
state.visible += chunk;
},
};
return state;
});
vi.mock('./use-xterm', () => ({
useXterm: () => ({
clear: xterm.clear,
containerRef: { current: null },
isReady: true,
scrollToBottom: xterm.scrollToBottom,
searchAddon: null,
write: xterm.write,
}),
}));
vi.mock('./use-terminal-search', () => ({
useTerminalSearch: () => ({ findNext: () => {}, findPrevious: () => {} }),
}));
vi.mock('@/hooks/use-theme', () => ({ useTheme: () => ({ theme: 'dark' }) }));
vi.mock('./terminal-sanitizer', () => ({ processLog: (line: string) => line }));
import Terminal from './terminal';
const visibleLines = () => xterm.visible.split('\r\n').filter(Boolean);
const reset = () => {
xterm.visible = '';
xterm.clearCount = 0;
};
describe('Terminal incremental writer', () => {
it('restores the full log set after a filter narrows then clears', () => {
reset();
const full = ['alpha-0', 'bravo-1', 'alpha-2', 'bravo-3', 'alpha-4'];
const filtered = ['bravo-1', 'bravo-3'];
const { rerender } = render(<Terminal logs={full} />);
expect(visibleLines()).toEqual(full);
rerender(<Terminal logs={filtered} />);
expect(visibleLines()).toEqual(filtered);
rerender(<Terminal logs={full} />);
expect(visibleLines()).toEqual(full);
});
it('appends streamed lines incrementally without clearing', () => {
reset();
const { rerender } = render(<Terminal logs={['line-0']} />);
rerender(<Terminal logs={['line-0', 'line-1']} />);
rerender(<Terminal logs={['line-0', 'line-1', 'line-2']} />);
expect(visibleLines()).toEqual(['line-0', 'line-1', 'line-2']);
expect(xterm.clearCount).toBe(0);
});
it('rewrites when the filter changes to a different, non-prefix set', () => {
reset();
const full = ['keep-0', 'drop-1', 'keep-2', 'drop-3'];
const { rerender } = render(<Terminal logs={full} />);
rerender(<Terminal logs={['keep-0', 'keep-2']} />);
rerender(<Terminal logs={['drop-1', 'drop-3']} />);
expect(visibleLines()).toEqual(['drop-1', 'drop-3']);
});
});
@@ -28,8 +28,7 @@ function Terminal({
const { clear, containerRef, isReady, scrollToBottom, searchAddon, write } = useXterm({ theme });
const { findNext, findPrevious } = useTerminalSearch(searchAddon, isReady, searchValue, theme);
const lastLogIndexRef = useRef(0);
const prevLogsLengthRef = useRef(0);
const renderedLogsRef = useRef<string[]>([]);
useImperativeHandle(ref, () => ({ findNext, findPrevious }), [findNext, findPrevious]);
@@ -38,20 +37,25 @@ function Terminal({
return;
}
if (logs.length === 0 && prevLogsLengthRef.current > 0) {
clear();
lastLogIndexRef.current = 0;
prevLogsLengthRef.current = 0;
return;
}
const rendered = renderedLogsRef.current;
if (logs.length === 0) {
if (rendered.length > 0) {
clear();
renderedLogsRef.current = [];
}
return;
}
if (logs.length >= lastLogIndexRef.current) {
const newLogs = logs.slice(lastLogIndexRef.current);
// Append the tail only when the on-screen lines are an exact prefix of
// the new array. `logs` is also fed filtered subsets (flow-terminal), so
// a length-only check reappends the tail over stale lines when it shrinks
// then grows back.
const isAppend = logs.length >= rendered.length && rendered.every((line, index) => line === logs[index]);
if (isAppend) {
const newLogs = logs.slice(rendered.length);
if (newLogs.length > 0) {
const batch = newLogs.filter(Boolean).map(processLog).join('\r\n');
@@ -73,8 +77,7 @@ function Terminal({
scrollToBottom();
}
lastLogIndexRef.current = logs.length;
prevLogsLengthRef.current = logs.length;
renderedLogsRef.current = logs;
}, [logs, isReady, write, clear, scrollToBottom]);
return (