Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
d69f7aa
fix(cli): clear the VP viewport on wake/SIGCONT repaints
qinqi-ali Aug 10, 2026
6bea8b7
fix(cli): repaint VP from a clean viewport after width shrinks
qinqi-ali Aug 10, 2026
0ba19b4
fix(cli): enable DEC synchronized output on Warp to reduce redraw fli…
qinqi-ali Aug 10, 2026
cb1297a
merge: consolidate DEC 2026 allowlist (Warp/Ghostty) into the repaint…
qinqi-ali Aug 10, 2026
2515655
merge: sync main (pull .github/actions/verify-checkout-head for CI)
qinqi-ali Aug 10, 2026
575ad26
fix(cli): guarantee VP wake repaint by replaying the last frame
qinqi-ali Aug 10, 2026
50d755b
fix(cli): address #8831 review — LIFO teardown, grow reset, bare-redr…
qinqi-ali Aug 10, 2026
c3229b2
fix(cli): address #8831 R3 review — model fidelity and wake remount
qinqi-ali Aug 10, 2026
dad05dc
fix(cli): address #8831 R3 review — raw-repack reflow model, wake rem…
qinqi-ali Aug 10, 2026
c4b5468
fix(cli): address #8831 R4 review — stripped-width model, legacy wake…
qinqi-ali Aug 10, 2026
4f7b4ba
fix(cli): address #8831 R4 review — stripped-width modeling, trusted-…
qinqi-ali Aug 10, 2026
15d4be6
test(cli): force the sync wrapper in the LIFO teardown test for CI de…
qinqi-ali Aug 10, 2026
d2e01e8
refactor(cli): drop unused exports from terminalRedrawOptimizer
qinqi-ali Aug 10, 2026
7584697
fix(cli): address #8831 R6 review — trusted-anchor amplify with prefi…
qinqi-ali Aug 10, 2026
78469b4
fix(cli): address #8831 R7 review — bounded bare-write handoff, windo…
qinqi-ali Aug 11, 2026
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
64 changes: 60 additions & 4 deletions packages/cli/src/ui/AppContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/

const { writeTerminalTitleSpy } = vi.hoisted(() => ({
const { writeTerminalTitleSpy, useWakeRepaintMock } = vi.hoisted(() => ({
writeTerminalTitleSpy: vi.fn(),
useWakeRepaintMock: vi.fn(),
}));

vi.mock('./hooks/use-wake-repaint.js', () => ({
useWakeRepaint: useWakeRepaintMock,
}));

vi.mock('../utils/windowTitle.js', async (importOriginal) => {
Expand Down Expand Up @@ -954,7 +959,7 @@ describe('AppContainer State Management', () => {
expect(mockStdout.write).toHaveBeenCalledWith(ansiEscapes.clearTerminal);
});

it('refreshStatic skips the physical clear in VP mode (#4891)', () => {
it('refreshStatic stays write-free in VP mode for ordinary callers (#8557)', () => {
const vpSettings = {
merged: {
hideTips: false,
Expand All @@ -980,13 +985,64 @@ describe('AppContainer State Management', () => {

capturedUIActions.refreshStatic();

// VP mode owns the viewport via the React tree, so refreshStatic must not
// emit a physical clear — the resize-settle path (#4891) strands nothing.
// Ordinary callers (/clear, model change, Ctrl+O, ...) must not
// trigger a physical clear-and-replay in VP: replaying the pre-change
// frame would flash stale content. Their refresh comes from the state
// change that triggered them; only the wake path repaints physically.
expect(mockStdout.write).not.toHaveBeenCalledWith(
ansiEscapes.clearViewport,
);
expect(mockStdout.write).not.toHaveBeenCalledWith(
ansiEscapes.clearTerminal,
);
});

// The wake/SIGCONT trigger itself is covered by use-wake-repaint.test.ts
// (SIGCONT/heartbeat-gap -> repaint callback); the VP/static selection is
// unit-covered by buildWakeRepaint tests. This test locks the AppContainer
// call site: the callback handed to the hook must be the wake repaint
// (repaintViewport + remount), not refreshStatic or a mis-wired memo.
it('wires the wake repaint (not refreshStatic) into useWakeRepaint', async () => {
useWakeRepaintMock.mockClear();
const repaintSpy = vi.fn();
const vpSettings = {
merged: {
hideTips: false,
theme: 'default',
ui: {
showStatusInTitle: false,
hideWindowTitle: false,
useTerminalBuffer: true,
},
},
setValue: vi.fn(),
} as unknown as LoadedSettings;

render(
<AppContainer
config={mockConfig}
settings={vpSettings}
version="1.0.0"
initializationResult={mockInitResult}
repaintViewport={repaintSpy}
/>,
);

// Let ink-testing-library's scheduled initial render flush.
await Promise.resolve();
const wakeCallback = useWakeRepaintMock.mock.calls.at(-1)?.[0];
expect(typeof wakeCallback).toBe('function');
act(() => {
wakeCallback();
});
// A revert to useWakeRepaint(refreshStatic) would leave the spy
// uncalled (VP refreshStatic is write-free) and fail here.
expect(repaintSpy).toHaveBeenCalledTimes(1);
Comment thread
chiga0 marked this conversation as resolved.
Outdated
// A repaint-only wiring (dropping the static remount bump) must not
// masquerade as the wake repaint.
expect(wakeCallback).not.toBe(repaintSpy);
Comment thread
chiga0 marked this conversation as resolved.
Outdated
});

it('defaults to VP mode when useTerminalBuffer is unset', () => {
const defaultSettings = {
merged: {
Expand Down
74 changes: 62 additions & 12 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
buildResumedHistoryItems,
expandCollapsedHistory,
} from './utils/resumeHistoryUtils.js';
import { buildWakeRepaint } from './utils/terminal-resize-reflow.js';
import { loadLowlight } from './utils/lowlightLoader.js';
import {
getStickyTodos,
Expand Down Expand Up @@ -624,6 +625,13 @@ interface AppContainerProps {
initializationResult: InitializationResult;
initialUseVirtualViewport?: boolean;
extensionRefreshState?: ExtensionRefreshState;
/**
* VP wake/SIGCONT repaint: clear the viewport and replay the last frame
* (Ink skips unchanged-output redraws, so a bare clear would blank the
* screen). Absent under QWEN_CODE_LEGACY_RESIZE_ERASE: the VP wake path
* stays write-free (static remount bump only), matching pre-PR behavior.
*/
repaintViewport?: () => void;
}

/**
Expand All @@ -639,8 +647,13 @@ const SHELL_WIDTH_FRACTION = 0.89;
const SHELL_HEIGHT_PADDING = 10;

export const AppContainer = (props: AppContainerProps) => {
const { settings, config, initializationResult, initialUseVirtualViewport } =
props;
const {
settings,
config,
initializationResult,
initialUseVirtualViewport,
repaintViewport,
} = props;
const extensionRefreshState = useMemo(
() => props.extensionRefreshState ?? new ExtensionRefreshState(),
[props.extensionRefreshState],
Expand Down Expand Up @@ -1287,15 +1300,16 @@ export const AppContainer = (props: AppContainerProps) => {
}, []);

// In VP mode (ui.useTerminalBuffer) the React tree fully owns the visible
// region via ink 7 native overflow clipping. Writing clearTerminal /
// cursorTo+eraseDown would be a wasted flash and would also corrupt the
// in-app scroll position. The remount-key bump is also a near-no-op for
// VP: nothing in the VP render path is keyed by historyRemountKey, so
// keeping the bump is harmless because the startup-scoped VP decision
// is intentionally restart-only to match Ink's alternateScreen lifetime.
// The visible refresh in VP mode comes for free from the React tree
// re-reading `mergedHistory` / `allVirtualItems` on whatever state
// change triggered refreshStatic (Ctrl+O, model change, etc.).
// region via ink 7 native overflow clipping. The remount-key bump is
// write-free but not inert: one-shot <Static> output keyed by it (agent
// tab history in AgentChatContent) is only re-emitted on a bump.
// refreshStatic must stay write-free in VP: ordinary callers (Ctrl+O,
// model change, /clear, ...) get their visible refresh from the state
// change that triggered them, and replaying the pre-change frame would
// flash stale content. Only the wake/SIGCONT path (wakeRepaint below) does
// a physical clear-and-replay, because there the terminal buffer may be
// stale or rearranged while Ink both erases with a stale relative count
// and skips redraws whose output is unchanged.
const [useTerminalBuffer] = useState(
() =>
initialUseVirtualViewport ??
Expand All @@ -1305,14 +1319,50 @@ export const AppContainer = (props: AppContainerProps) => {
isInteractiveTerminal(),
),
);

// The VP post-shrink clear window (terminal-resize-reflow) wipes one-shot
// <Static> content from the viewport just like the wake path; pair it with
// the same remount bump so keyed statics (agent tab history) re-emit.
const prevTerminalWidthRef = useRef(terminalWidth);
useEffect(() => {
const prev = prevTerminalWidthRef.current;
prevTerminalWidthRef.current = terminalWidth;
if (useTerminalBuffer && terminalWidth < prev) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Three things about the new VP shrink-remount effect (R6-6 already covers the missing test, so not repeated here): (a) it bumps remountStaticHistory() on every shrink render — during a drag-shrink burst each width tick re-emits the full one-shot static history, and each re-emit is wiped by the next in-window CLEAR_VIEWPORT substitute (the 650 ms re-bump is the one that actually lands); (b) it is not gated on the reflow wrapper being active — under QWEN_CODE_LEGACY_RESIZE_ERASE=1 (wrapper is a no-op, no viewport clears) it still bumps twice per shrink, where pre-PR behavior was no bump at all; (c) 650 duplicates CLEAR_WINDOW_MS (600, exported from terminal-resize-reflow.ts) by value, coupled only by a comment.

Suggested fix: bump only on the first shrink of a burst (when no timer is pending); gate on repaintViewport being present (undefined exactly in legacy-hatch mode); derive the delay as CLEAR_WINDOW_MS + 50.

中文说明

新的 VP 缩窄 remount effect 有三个问题(R6-6 已覆盖缺测试,此处不重复):(a) 每次缩窄渲染都立即整体重发 static 历史——拖拽缩窄的每个宽度 tick 都触发一次,而窗口内每次重发又会被下一次 CLEAR_VIEWPORT 替换清掉(真正生效的是 650ms 重 bump);(b) 未门控 reflow wrapper 是否激活——QWEN_CODE_LEGACY_RESIZE_ERASE=1 下(wrapper 为 no-op)每次缩窄仍 bump 两次,而 PR 前该模式不触发任何 bump;(c) 650 与 CLEAR_WINDOW_MS(600,已导出)跨文件硬耦合。建议:burst 首次缩窄才立即 bump;以 repaintViewport 是否存在作为门控;延迟改为 CLEAR_WINDOW_MS + 50。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 936b1ad: (a) the immediate bump fires only on the first shrink of a burst (no timer pending); in-window ticks just reschedule the window-end re-bump; (b) the effect is gated on repaintViewport being present, which is undefined exactly under QWEN_CODE_LEGACY_RESIZE_ERASE (wrapper no-op), restoring pre-PR no-bump behavior there; (c) the delay is CLEAR_WINDOW_MS + 50 via the exported constant, no duplicated literal.

remountStaticHistory();
}
Comment thread
chiga0 marked this conversation as resolved.
}, [terminalWidth, useTerminalBuffer, remountStaticHistory]);

const showScrollbar = settings.merged.ui?.showScrollbar ?? true;
const refreshStatic = useCallback(() => {
if (!useTerminalBuffer) {
stdout.write(ansiEscapes.clearTerminal);
}
// VP stays write-free for ordinary callers (/clear, model change, Ctrl+O,
// ...): replaying the pre-change frame would flash stale content. Their
// visible refresh comes from the state change that triggered them. The
// wake/SIGCONT path repaints separately via useWakeRepaint below.
remountStaticHistory();
}, [useTerminalBuffer, remountStaticHistory, stdout]);

// Wake/SIGCONT: the terminal buffer may be stale or rearranged, and Ink
// both erases with a stale relative count and skips redraws whose output
// is unchanged — so VP repaints by replaying the last frame over a clean
// viewport (viewport-only: clearTerminal's 3J would destroy scrollback /
// Warp history) and bumps the static remount key so one-shot <Static>
// history (agent tabs) is re-emitted over the clear. Static mode uses the
// ordinary refreshStatic. Selection extracted (buildWakeRepaint) for unit
// coverage.
const wakeRepaint = useMemo(
() =>
buildWakeRepaint({
isVP: useTerminalBuffer,
repaintViewport,
refreshStatic,
remountStaticHistory,
}),
[useTerminalBuffer, repaintViewport, refreshStatic, remountStaticHistory],
);

// Keep the static header in sync with model changes without polling.
// Ink's <Static> output is append-only, so model changes must explicitly
// clear and remount the static region to redraw the banner at the top.
Expand Down Expand Up @@ -3402,7 +3452,7 @@ export const AppContainer = (props: AppContainerProps) => {
// 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);
useWakeRepaint(wakeRepaint);
Comment thread
chiga0 marked this conversation as resolved.

useEffect(() => {
if (ideNeedsRestart) {
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/ui/startInteractiveUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
pushKittyProtocolFlags,
} from './utils/kittyProtocolDetector.js';
import { installTerminalRedrawOptimizer } from './utils/terminalRedrawOptimizer.js';
import { installTerminalResizeReflow } from './utils/terminal-resize-reflow.js';
Comment thread
chiga0 marked this conversation as resolved.
import { installSynchronizedOutput } from './utils/synchronizedOutput.js';
import {
isInteractiveTerminal,
Expand Down Expand Up @@ -164,6 +165,15 @@ export async function startInteractiveUI(
isInteractiveTerminal(),
);

// On width shrink the terminal reflows the printed frame into more physical
// rows than Ink's stale erase count (issue #8557); amplify the clear to the
// reflowed height. Installed before render() so the resize listener runs
// ahead of Ink's resized().
const resizeReflow =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No regression test drives a real terminal through the shrink path. The 38 unit tests validate the erase-amplification model against the wrapper's own reflow assumption — FakeStdout plus the same greedyRows/stringWidth/Intl.Segmenter packing the production code uses — so model and test share any width-modeling error. The #8557 thread itself documents a divergence already observed on Warp (CJK font fallback renders ∴ full-width while any app-side width model counts it narrow).

Failure scenario: the width model diverges from a real terminal's reflow, and the issue's exact symptom (duplicated/stacked transcript on shrink) returns on real terminals while all unit tests stay green.

Suggested fix: commit a PTY/tmux-based regression in the integration harness that shrinks a real terminal mid-session and asserts a single reprint; at minimum, capture the byte-level shrink rig output as a fixture.

中文说明

没有任何回归测试驱动真实终端走缩窄路径:38 个单测用 FakeStdout + 与生产代码同一套打包逻辑验证放大模型,模型与测试共享任何宽度计算错误;#8557 线程里作者自己已记录过 Warp 上的真实分歧(CJK 字体回退把 ∴ 渲染成整宽,任何应用侧宽度模型都按窄字计)。建议在集成测试里提交 PTY/tmux 缩窄回归,或至少把字节级缩窄 rig 输出固化为 fixture。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted as a known limitation, deferred: a PTY/tmux shrink regression would not share the width-model assumption, but the repo's interactive tmux harness is flaky-prone in CI and the byte-level rig would still bind to one terminal's reflow semantics (Warp's CJK fallback diverges from any app-side model, as documented in this thread). Mitigations in place: 38+ unit tests pin the wrapper contract, and the fix was author-verified on Ghostty and Warp (live-view duplication gone; Warp block-history snapshots remain platform behavior, documented). Tracking a real-terminal shrink regression as follow-up work; not blocking this PR.

process.stdout.isTTY && !config.getScreenReader()
? installTerminalResizeReflow(process.stdout, { virtualViewport: useVP })
: { restore: () => {}, repaint: () => {} };

// Create wrapper component to use hooks inside render
const AppWrapper = () => {
const kittyProtocolStatus = useKittyKeyboardProtocol();
Expand Down Expand Up @@ -195,6 +205,7 @@ export async function startInteractiveUI(
initializationResult={initializationResult}
initialUseVirtualViewport={useVP}
extensionRefreshState={options.extensionRefreshState}
repaintViewport={resizeReflow.repaint}
/>
</BackgroundTaskViewProvider>
</AgentViewProvider>
Expand Down Expand Up @@ -313,6 +324,10 @@ export async function startInteractiveUI(
if (useVP) {
process.stdout.setMaxListeners(stdoutMaxListeners);
}
// Unwind the stdout.write wrapper stack in LIFO order (resizeReflow is
// installed last / outermost); the identity-guarded restores silently
// no-op and leak wrappers otherwise.
resizeReflow.restore();
restoreSynchronizedOutput();
Comment thread
chiga0 marked this conversation as resolved.
restoreTerminalRedrawOptimizer();
// If the ErrorBoundary caught a rendering error, echo it to stderr
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/utils/synchronizedOutput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ function createStdout(write: NodeJS.WriteStream['write']): NodeJS.WriteStream {
describe('terminalSupportsSynchronizedOutput', () => {
it.each([
[{ TERM_PROGRAM: 'WezTerm' }, true],
[{ TERM_PROGRAM: 'WarpTerminal' }, true],
[{ TERM_PROGRAM: 'ghostty' }, true],
[{ TERM_PROGRAM: 'iTerm.app' }, true],
[{ TERM: 'xterm-kitty' }, true],
Comment thread
chiga0 marked this conversation as resolved.
[{ KITTY_WINDOW_ID: '1' }, true],
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/ui/utils/synchronizedOutput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,16 @@ export function terminalSupportsSynchronizedOutput(
}

const termProgram = env['TERM_PROGRAM'];
if (termProgram === 'WezTerm' || termProgram === 'iTerm.app') {
// Warp's DECRQM 2026 probe answers status 2 (recognized, reset), so
// synchronized updates are available there; without them Warp renders the
// erase-then-rewrite pattern as flicker (issue #8557). Ghostty implements
// synchronized output natively.
if (
termProgram === 'WezTerm' ||
termProgram === 'iTerm.app' ||
termProgram === 'WarpTerminal' ||
termProgram === 'ghostty'
) {
return true;
}

Expand Down
Loading
Loading