Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
74 changes: 69 additions & 5 deletions packages/cli/src/ui/AppContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/

const { writeTerminalTitleSpy } = vi.hoisted(() => ({
writeTerminalTitleSpy: vi.fn(),
const { writeTerminalTitleSpy, useWakeRepaintMock, buildWakeRepaintSpy } =
vi.hoisted(() => ({
writeTerminalTitleSpy: vi.fn(),
useWakeRepaintMock: vi.fn(),
buildWakeRepaintSpy: vi.fn((deps: Record<string, unknown>) =>
vi.fn(() => deps),
),
}));

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

vi.mock('./utils/terminal-resize-reflow.js', () => ({
buildWakeRepaint: buildWakeRepaintSpy,
}));

vi.mock('../utils/windowTitle.js', async (importOriginal) => {
Expand Down Expand Up @@ -954,7 +967,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 +993,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();
buildWakeRepaintSpy.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();
// The call site must build the wake callback via buildWakeRepaint with
// the repaint prop AND the static remount bump in its deps; inline
// repaint-only wrappers (the shape that drops the agent-tab <Static>
// re-emit) fail these.
const deps = buildWakeRepaintSpy.mock.calls.at(-1)?.[0];
expect(deps?.['isVP']).toBe(true);
expect(deps?.['repaintViewport']).toBe(repaintSpy);
expect(typeof deps?.['remountStaticHistory']).toBe('function');
const wakeCallback = useWakeRepaintMock.mock.calls.at(-1)?.[0];
expect(wakeCallback).toBe(buildWakeRepaintSpy.mock.results.at(-1)?.value);
});

it('defaults to VP mode when useTerminalBuffer is unset', () => {
const defaultSettings = {
merged: {
Expand Down
93 changes: 81 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,69 @@ 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. The
// window's CLEAR_VIEWPORT substitutes wipe the just-re-emitted statics on
// every in-window redraw, so bump again once the window closes.
const prevTerminalWidthRef = useRef(terminalWidth);
const shrinkRemountTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
useEffect(() => {
const prev = prevTerminalWidthRef.current;
prevTerminalWidthRef.current = terminalWidth;
Comment on lines +1328 to +1334

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] R6-6: The new VP width-shrink remount effect has no test anywhere in the diff or the existing suite. The only resize-remount assertion is the #8004 test, which GROWS the width (80 → 100) in STATIC mode (useTerminalBuffer: false) and asserts the key does not change — it cannot see this effect. — Concrete cost (mutation-verified): terminalWidth < prevterminalWidth > prev, or deleting the effect, leaves every test green. The regression that would then ship uncaught: after a VP-mode terminal shrink, the reflow wrapper's 2J+H viewport clear wipes one-shot <Static> agent-tab history, and without the bump that history never re-emits until the next unrelated remount trigger — a visible-loss artifact of the same class this PR fixes. The wake path's identical bump IS covered (buildWakeRepaint unit tests); only the shrink path's is not.

Add a case in the #8004 style with VP settings: deliver a width shrink (e.g. 100 → 80) via the same resizeListeners mechanism and assert capturedUIState.historyRemountKey incremented; optionally a companion case asserting a grow or a static-mode shrink does not bump.

中文说明

[Suggestion] R6-6:新增的 VP 宽度缩窄 remount effect 在 diff 与现有套件中均无测试。唯一的 resize-remount 断言是 #8004 测试,它在 STATIC 模式(useTerminalBuffer: false)下把宽度增大(80 → 100)并断言 key 不变——无法覆盖该 effect。— 具体代价(已变异验证):把 terminalWidth < prev 改成 terminalWidth > prev,或删除整个 effect,所有测试仍为绿。随后会无声合入的回归:VP 模式缩窄终端后,reflow wrapper 的 2J+H 视口清除会清掉一次性 <Static> agent 标签页历史,若没有该自增,历史在下一次无关的 remount 触发之前永不重新发出——与本 PR 修复目标同类的可见丢失缺陷。wake 路径的相同自增已有覆盖(buildWakeRepaint 单测);只有缩窄路径没有。

#8004 风格补一个 VP 配置用例:通过同一 resizeListeners 机制投递一次宽度缩窄(如 100 → 80),断言 capturedUIState.historyRemountKey 自增;可再加一个用例断言变宽或 static 模式缩窄不自增。

— qwen3.8-max via Qwen Code /review (v0.21.9)

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();
if (shrinkRemountTimerRef.current) {
clearTimeout(shrinkRemountTimerRef.current);
}
// Slightly past CLEAR_WINDOW_MS (600) so the last window clear lands
// before the re-emit.
shrinkRemountTimerRef.current = setTimeout(remountStaticHistory, 650);
}
Comment thread
chiga0 marked this conversation as resolved.
}, [terminalWidth, useTerminalBuffer, remountStaticHistory]);
useEffect(
() => () => {
if (shrinkRemountTimerRef.current) {
clearTimeout(shrinkRemountTimerRef.current);
}
},
[],
);

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 +3471,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();
Comment on lines +327 to +330

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] R6-7: The only production call site establishing the wrapper stack — the install gate (isTTY && !getScreenReader()), the repaintViewport={resizeReflow.repaint} pass-through, and this three-call LIFO unwind — has no test; startInteractiveUI has no test file at all. — Concrete cost: the 'wrapper restores unwind in LIFO order only' test proves the contract with its own locally built install order, and its negative half demonstrates exactly this hazard (out-of-order restore makes the identity guards no-op, leaving stdout.write patched), but nothing ties the actual restore sequence here to that contract. If a future edit reorders these three cleanup calls, the identity-guarded restores silently no-op and the stale reflow wrapper keeps intercepting stdout for the rest of the process lifetime, amplifying erases against a stale width model on post-exit output — the failure is silent by design. The same gap covers the non-TTY/screen-reader stub branch.

If a full startInteractiveUI test is out of reach, extract the cleanup ordering into a small testable helper, or add a narrow integration assertion that process.stdout.write is identity-restored after the UI exits.

中文说明

[Suggestion] R6-7:建立 wrapper 栈的唯一生产调用点——安装门(isTTY && !getScreenReader())、repaintViewport={resizeReflow.repaint} 透传、以及这三行的 LIFO 解开——没有测试;startInteractiveUI 根本没有测试文件。— 具体代价:'wrapper restores unwind in LIFO order only' 测试用它自己本地构造的安装顺序证明了该契约,其反例半边恰好演示了此危害(非 LIFO 恢复会使同一性守卫空操作、stdout.write 保持被包装),但没有任何东西把此处真实的恢复顺序与该契约绑定。若未来某次编辑重排这三个清理调用,同一性守卫会静默空操作,陈旧的 reflow wrapper 将在进程余生持续拦截 stdout,用陈旧的宽度模型对退出后的输出放大擦除——该失效按设计就是无声的。同样的缺口也覆盖非 TTY/读屏器 stub 分支。

若完整的 startInteractiveUI 测试不可行,可把清理顺序抽成一个小的可测辅助函数,或加一个窄的集成断言:UI 退出后 process.stdout.write 被同一性恢复。

— qwen3.8-max via Qwen Code /review (v0.21.9)

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