Skip to content

Commit 9933cfa

Browse files
fix(twinki): avoid full redraw for off-screen animation ticks (#1433)
* local * fix(twinki): avoid full redraw for off-screen animation ticks - Skip static output accumulation/prepend in alt screen (no scrollback) - Replace full CLEAR_ALL redraw with viewport-only re-scan when content didn't shrink and changes are above the viewport. This prevents flashing and scrollback wipe from spinner/thinking animations that scroll past the viewport during long streaming responses. - Shrink case still falls through to full redraw (stale rows need clearing) --------- Co-authored-by: Kenneth S. <kennvene@amazon.com>
1 parent d66c16e commit 9933cfa

2 files changed

Lines changed: 35 additions & 11 deletions

File tree

packages/tui/src/components/ui/CommandMenu.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,19 @@ export const CommandMenu: React.FC = () => {
7575
return match?.[1] ?? '';
7676
}, [commandInputValue, activeTrigger]);
7777

78-
// Search files when query changes
78+
// Debounce file search so rapid keystrokes (e.g. typing @kennvene) don't
79+
// trigger a synchronous readdirSync walk on every character.
7980
useEffect(() => {
8081
if (activeTrigger?.key === '@' && fileQuery) {
81-
const results = searchFiles(fileQuery);
82-
setFileResults(results);
83-
setFilePickerHasResults(results.length > 0);
84-
} else {
85-
setFileResults([]);
86-
setFilePickerHasResults(false);
82+
const timer = setTimeout(() => {
83+
const results = searchFiles(fileQuery);
84+
setFileResults(results);
85+
setFilePickerHasResults(results.length > 0);
86+
}, 150);
87+
return () => clearTimeout(timer);
8788
}
89+
setFileResults([]);
90+
setFilePickerHasResults(false);
8891
}, [fileQuery, activeTrigger, setFilePickerHasResults]);
8992

9093
const filteredCommands = useMemo(() => {

packages/twinki/packages/twinki/src/renderer/tui.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,7 +1038,7 @@ export class TUI extends Container {
10381038
* These lines are rendered above live content and scroll into terminal scrollback.
10391039
*/
10401040
writeStaticLines(lines: string[]): void {
1041-
if (lines.length > 0) {
1041+
if (lines.length > 0 && !this.altScreen) {
10421042
this.accumulatedStaticOutput.push(...lines);
10431043
this.trimStaticOutput();
10441044
// When content overflowed the viewport, old active content is stuck
@@ -1266,7 +1266,8 @@ export class TUI extends Container {
12661266

12671267
// OPTIMIZED: Combine accumulated static output with live content
12681268
// Use concat instead of spread operator for better performance with large arrays
1269-
if (this.accumulatedStaticOutput.length > 0) {
1269+
// Skip in alt screen — no scrollback buffer to display static content in.
1270+
if (this.accumulatedStaticOutput.length > 0 && !this.altScreen) {
12701271
newLines = this.accumulatedStaticOutput.concat(newLines);
12711272
}
12721273

@@ -1410,9 +1411,29 @@ export class TUI extends Container {
14101411
return;
14111412
}
14121413

1413-
// Change above previous viewport — full redraw to clear old lines.
1414+
// Change above previous viewport.
1415+
// When content didn't shrink, re-scan only the visible viewport instead
1416+
// of a full redraw. This avoids flashing from off-screen animation ticks
1417+
// (spinners, thinking indicators) that would otherwise trigger CLEAR_ALL.
14141418
const previousContentViewportTop = Math.max(0, this.previousLines.length - height);
1415-
if (firstChanged < previousContentViewportTop) {
1419+
if (firstChanged < previousContentViewportTop && newLines.length >= this.previousLines.length) {
1420+
firstChanged = -1;
1421+
lastChanged = -1;
1422+
for (let i = previousContentViewportTop; i < Math.max(newLines.length, this.previousLines.length); i++) {
1423+
const oldLine = this.previousLines[i] ?? '';
1424+
const newLine = newLines[i] ?? '';
1425+
if (oldLine !== newLine) {
1426+
if (firstChanged === -1) firstChanged = i;
1427+
lastChanged = i;
1428+
}
1429+
}
1430+
if (firstChanged === -1) {
1431+
this.previousLines = newLines;
1432+
this.previousWidth = width;
1433+
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
1434+
return;
1435+
}
1436+
} else if (firstChanged < previousContentViewportTop) {
14161437
fullRender(this.altScreen ? CLEAR_SCREEN : CLEAR_ALL, 'off-screen-change');
14171438
return;
14181439
}

0 commit comments

Comments
 (0)