Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ const STARTUP_PROFILE_FINALIZE_CAP_MS = 35_000;
import { useHistory } from './hooks/useHistoryManager.js';
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
import { useResizeSettleRepaint } from './hooks/useResizeSettleRepaint.js';
import { useWakeRepaint } from './hooks/use-wake-repaint.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useFeedbackDialog } from './hooks/useFeedbackDialog.js';
import { useAuthCommand } from './auth/useAuth.js';
Expand Down Expand Up @@ -3096,6 +3097,12 @@ export const AppContainer = (props: AppContainerProps) => {
// Repaint static history on the trailing edge of a resize burst (#4891).
useResizeSettleRepaint(terminalWidth, refreshStatic);

// Repaint after the process resumes from OS sleep / suspend (lid close,
// display sleep, Ctrl+Z → fg). The terminal's screen buffer is stale but
// Ink's frame-diff state still reflects the pre-sleep output, so the next
// render strands border characters on screen.
useWakeRepaint(refreshStatic);

useEffect(() => {
if (ideNeedsRestart) {
// IDE trust changed, force a restart.
Expand Down
106 changes: 106 additions & 0 deletions packages/cli/src/ui/hooks/use-wake-repaint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useWakeRepaint } from './use-wake-repaint.js';

const HEARTBEAT_MS = 5_000;
const WAKE_THRESHOLD_MS = HEARTBEAT_MS * 2;

describe('useWakeRepaint', () => {
Comment thread
wenshao marked this conversation as resolved.
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

const setup = () => {
const repaint = vi.fn();
const view = renderHook(() => useWakeRepaint(repaint));
return { repaint, view };
};

it('does not repaint during normal heartbeat ticks', () => {
const { repaint } = setup();

// Advance through several normal heartbeat intervals.
act(() => vi.advanceTimersByTime(HEARTBEAT_MS * 5));

expect(repaint).not.toHaveBeenCalled();
});

it('repaints when a heartbeat gap exceeds the wake threshold', () => {
const { repaint } = setup();

// First tick at t=5000 — normal.
act(() => vi.advanceTimersByTime(HEARTBEAT_MS));
expect(repaint).not.toHaveBeenCalled();

// Simulate sleep: jump the clock far ahead so the next tick sees a gap
// larger than WAKE_THRESHOLD_MS.
vi.setSystemTime(Date.now() + WAKE_THRESHOLD_MS + 1_000);
act(() => vi.advanceTimersByTime(HEARTBEAT_MS));

expect(repaint).toHaveBeenCalledTimes(1);
});

it('repaints on SIGCONT', () => {
const { repaint } = setup();

act(() => {
process.emit('SIGCONT');
});
Comment thread
wenshao marked this conversation as resolved.

expect(repaint).toHaveBeenCalledTimes(1);
});

it('does not repaint on SIGCONT after unmount', () => {
const { repaint, view } = setup();

view.unmount();

act(() => {
process.emit('SIGCONT');
});

expect(repaint).not.toHaveBeenCalled();
});

it('cleans up the heartbeat timer on unmount', () => {
const { repaint, view } = setup();

view.unmount();

// Advance well past the wake threshold — no timer should fire.
vi.setSystemTime(Date.now() + WAKE_THRESHOLD_MS + 10_000);
act(() => vi.advanceTimersByTime(HEARTBEAT_MS * 5));

expect(repaint).not.toHaveBeenCalled();
});

it('uses the latest repaint callback without re-arming listeners', () => {
const first = vi.fn();
const second = vi.fn();

const view = renderHook(
({ cb }: { cb: () => void }) => useWakeRepaint(cb),
{ initialProps: { cb: first } },
);

// Swap the callback.
view.rerender({ cb: second });

act(() => {
process.emit('SIGCONT');
});

expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1);
});
});
68 changes: 68 additions & 0 deletions packages/cli/src/ui/hooks/use-wake-repaint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { useEffect, useRef } from 'react';

// How often the heartbeat timer fires.
const HEARTBEAT_INTERVAL_MS = 5_000;

// If the gap between two consecutive heartbeats exceeds this threshold the
// process was almost certainly suspended (macOS display sleep, system sleep,
// lid close, `Ctrl+Z` + `fg`, etc.). 2× the heartbeat interval gives ample
// margin for event-loop jitter while still catching any real suspend.
const WAKE_THRESHOLD_MS = HEARTBEAT_INTERVAL_MS * 2;

/**
* Repaint the UI when the process resumes after a suspend / sleep.
*
* After macOS display-sleep or system-sleep the terminal emulator's screen
* buffer may be reset or rearranged, but Ink's internal frame-diff state still
* reflects the pre-sleep output. The next render then moves the cursor to the
* wrong row and the erase-and-redraw cycle strands border / separator
* characters on screen (the "horizontal lines" artifact).
*
* Detection is two-pronged:
*
* 1. **Heartbeat timer** — a `setInterval` that records `Date.now()` on each
* tick. If the gap between ticks exceeds {@link WAKE_THRESHOLD_MS} the
* event loop was frozen (display sleep, system sleep, laptop lid close).
* The timer is `.unref()`'d so it never keeps the process alive.
*
* 2. **SIGCONT** — delivered when a stopped process is continued (`fg` after
* `Ctrl+Z`). The terminal's screen buffer is likewise stale in this case.
*
* `repaint` is read through a ref, so it does not need to be referentially
* stable — the listeners are armed once on mount and never re-created.
*/
export function useWakeRepaint(repaint: () => void): void {
const repaintRef = useRef(repaint);
repaintRef.current = repaint;

useEffect(() => {
let lastTick = Date.now();

const timer = setInterval(() => {
const now = Date.now();
const elapsed = now - lastTick;
lastTick = now;
if (elapsed > WAKE_THRESHOLD_MS) {
repaintRef.current();
}
}, HEARTBEAT_INTERVAL_MS);
timer.unref?.();

const onSigcont = () => {
lastTick = Date.now();
repaintRef.current();
};
process.on('SIGCONT', onSigcont);

return () => {
clearInterval(timer);
process.removeListener('SIGCONT', onSigcont);
};
}, []);
}
Loading