Skip to content

fix(cli): pop the kitty keyboard protocol after leaving the alternate screen - #7115

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
zjunothing:fix/6776-kitty-protocol-exit-cleanup
Jul 17, 2026
Merged

fix(cli): pop the kitty keyboard protocol after leaving the alternate screen#7115
wenshao merged 1 commit into
QwenLM:mainfrom
zjunothing:fix/6776-kitty-protocol-exit-cleanup

Conversation

@zjunothing

Copy link
Copy Markdown
Collaborator

What this PR does

Restores the terminal's keyboard state correctly on exit when the alternate screen buffer is in use. The Kitty keyboard protocol pop sequence is now written after Ink has unmounted — that is, after the alternate screen (when ui.useTerminalBuffer is enabled) has been left — so the pop applies to the main screen, where the protocol was originally pushed. Previously the pop was written while the alternate screen was still active, popping that screen's empty stack and leaving the main screen's keyboard flags permanently set.

Why it's needed

The kitty keyboard protocol spec tracks progressive-enhancement flags per screen (main and alternate screens have independent state). Qwen Code pushes the flags (ESC[>1u) on the main screen at startup, before Ink renders. With ui.useTerminalBuffer: true, Ink then enters the alternate screen, and the exit cleanup ran disableKittyProtocol() before instance.unmount() — so the pop (ESC[<u) landed inside the alternate screen and the main screen's flags survived the exit. On kitty-protocol terminals (Ghostty, Kitty, WezTerm), the user's shell then receives kitty escape fragments like 9;5u on Ctrl-C until they run reset or printf '\e[<u' — exactly the symptom in #6776, including the follow-up report that a clean /quit also reproduces it.

Reviewer Test Plan

How to verify

  1. In a kitty-protocol terminal (Ghostty/Kitty/WezTerm), set ui.useTerminalBuffer: true in ~/.qwen/settings.json.
  2. Run qwen, then exit (double Ctrl-C or /quit).
  3. Before this PR: pressing Ctrl-C (and other keys) in the shell after exit produces fragments like 9;5u; printf '\e[<u' fixes it. After this PR: the shell behaves normally.
  4. Regression: repeat without useTerminalBuffer (default) — behavior is unchanged and the terminal is restored correctly.

Automated coverage: a new regression test asserts disableKittyProtocol() is invoked only after instance.unmount() in the exit cleanup (gemini.test.tsx, 42/42 pass). npm run typecheck, eslint and prettier are clean on the changed files.

Evidence (Before & After)

A PTY harness runs the real bundled CLI (dist/cli.js, no mocks) inside a pseudo-terminal that answers the kitty-protocol detection probes (ESC[?u / ESC[c), drives a double-Ctrl-C exit, captures every output byte, and checks the order of the push (ESC[>1u), pop (ESC[<u), and alt-screen off (ESC[?1049l) sequences:

Build (useTerminalBuffer: true) Byte order observed Verdict
unpatched (origin/main @ 30984a2f5) push@14 → alt-on@41 → pop@33919 → alt-off@33945 ❌ pop inside alt screen; main-screen flags never cleared
this PR push@14 → alt-on@41 → alt-off@22593 → pop@22613 ✅ pop lands on the main screen
this PR, default config (no alt screen) push@14 → pop@18508 ✅ unchanged

verification screenshot

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

macOS (Darwin 24.6), Node v22.23.1; PTY harness via Python pty module against the esbuild bundle, plus vitest unit tests.

Risk & Scope

  • Main risk or tradeoff: the pop is now written a few cleanup steps later (after unmount instead of before). stdout remains a synchronous TTY stream through unmount, so delivery is not at risk; the process.on('exit'/'SIGINT'/'SIGTERM') fallbacks in kittyProtocolDetector.ts are untouched and still cover abnormal exits.
  • Not validated / out of scope: abnormal terminations that never reach the cleanup chain while the alternate screen is active (the terminal is then stuck in the alt screen anyway, a pre-existing broader issue); the original reporter's rapid-Ctrl-C timing on Linux/Ghostty was not separately reproducible on the current main — the PTY harness shows the default-config exit path already emits the pop correctly there.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #6776

中文说明

本 PR 做了什么

修复使用备用屏幕缓冲区时退出后终端键盘状态未恢复的问题。Kitty 键盘协议的 pop 序列现在改为在 Ink unmount 之后写入——即(启用 ui.useTerminalBuffer 时)已离开备用屏幕之后——使 pop 作用于最初 push 协议的主屏幕。此前 pop 在备用屏幕仍激活时写入,弹的是备屏的空栈,主屏的键盘 flags 被永久残留。

为什么需要

kitty 键盘协议规范中,渐进增强 flags 是按屏幕独立跟踪的(主屏与备屏状态互不影响)。Qwen Code 在启动时(Ink 渲染之前)在主屏 push flags(ESC[>1u)。当 ui.useTerminalBuffer: true 时,Ink 随后进入备屏,而退出清理在 instance.unmount() 之前调用了 disableKittyProtocol()——pop(ESC[<u)落在备屏内,主屏的 flags 在退出后仍然生效。在 kitty 协议终端(Ghostty、Kitty、WezTerm)上,退出后 shell 里按 Ctrl-C 会出现 9;5u 这类片段,必须运行 resetprintf '\e[<u' 才能恢复——与 #6776 的症状完全一致,包括后续评论中"干净的 /quit 也复现"。

审阅测试计划

如何验证

  1. 在 kitty 协议终端中,设置 ~/.qwen/settings.jsonui.useTerminalBuffer: true
  2. 运行 qwen 后退出(双击 Ctrl-C 或 /quit);
  3. 本 PR 之前:退出后在 shell 中按 Ctrl-C 会输出 9;5u 之类的片段,printf '\e[<u' 可恢复。本 PR 之后:shell 行为正常;
  4. 回归:不开 useTerminalBuffer(默认)重复上述步骤——行为不变,终端正确恢复。

自动化覆盖:新增回归测试断言退出清理中 disableKittyProtocol() 仅在 instance.unmount() 之后调用(gemini.test.tsx,42/42 通过)。typecheck / eslint / prettier 全部通过。

证据(Before & After)

PTY harness 在伪终端中运行真实打包产物(dist/cli.js,非 mock),应答 kitty 协议探测查询,驱动双 Ctrl-C 退出,捕获全部输出字节并检查 push / pop / 离开备屏序列的顺序(见上方表格与截图)。

测试平台

macOS 已本地验证(✅);Windows / Linux 依赖 CI(⚠️)。

环境

macOS(Darwin 24.6)、Node v22.23.1;Python pty 模块驱动 esbuild 打包产物 + vitest 单测。

风险与范围

  • 主要风险/权衡:pop 的写入时机在清理链中后移了几步(unmount 之后)。stdout 在 unmount 后仍是同步 TTY 流,送达无风险;kittyProtocolDetector.tsprocess.on('exit'/'SIGINT'/'SIGTERM') 的兜底逻辑未改动,异常退出仍有覆盖。
  • 未验证/超出范围:清理链未执行、备屏仍激活的异常终止场景(此时终端本就卡在备屏,属于既有的更大问题);原报告人在 Linux/Ghostty 上的快速 Ctrl-C 时序在当前 main 上未能单独复现——PTY 实测表明默认配置的退出路径已能正确发出 pop。
  • 破坏性变更/迁移说明:无。

关联 Issue

Fixes #6776

🤖 Generated with Claude Code

… screen

The kitty keyboard protocol is enabled (ESC[>1u) on the main screen
before Ink renders, but the exit cleanup wrote the pop (ESC[<u) before
instance.unmount(). With ui.useTerminalBuffer enabled, Ink runs in the
alternate screen, and the kitty spec tracks keyboard flags per screen:
the pop landed on the alternate screen's empty stack, unmount then left
the alternate screen, and the main screen's flags stayed set. After
exit, the shell received kitty escape codes (e.g. "9;5u" for Ctrl-C)
until the user ran `reset` or `printf '\e[<u'`.

Move disableKittyProtocol() after instance.unmount() so the pop is
written once the alternate screen (when enabled) has been left and
applies to the main screen where the protocol was pushed. The default
(no alternate screen) path is unaffected — verified with a PTY harness
that emulates a kitty-capable terminal in both configurations.

Fixes QwenLM#6776

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
zjunothing added a commit to zjunothing/qwen-code that referenced this pull request Jul 17, 2026
@zjunothing

Copy link
Copy Markdown
Collaborator Author

Verification Report

All verification was done locally on this branch (base: 30984a2f5, upstream main at the time of writing).

1. E2E reproduction — real CLI in a PTY, no mocks

A Python harness forks the real bundled CLI (node dist/cli.js) inside a pseudo-terminal that emulates a kitty-protocol-capable terminal: it answers the detection probes (ESC[?uESC[?1u, ESC[cESC[?62c) so the CLI genuinely enables the protocol, then drives a double-Ctrl-C exit and captures every byte the app writes. The analysis checks the byte offsets of the protocol push (ESC[>1u), pop (ESC[<u), and alternate-screen enter/leave (ESC[?1049h/l):

Build Config Observed byte order Verdict
unpatched origin/main useTerminalBuffer: true push@14 → alt-on@41 → pop@33919 → alt-off@33945 ❌ pop written inside the alt screen — main-screen flags never cleared
this PR useTerminalBuffer: true push@14 → alt-on@41 → alt-off@22593 → pop@22613 ✅ pop lands on the main screen
this PR default (no alt screen) push@14 → pop@18508 ✅ unchanged

Since the kitty spec tracks keyboard flags per screen, the unpatched order means the main screen keeps flags=1 after the process exits — the shell then receives kitty key encodings such as 9;5u for Ctrl-C, matching the issue symptom and the reporter's printf '\e[<u' workaround.

verification screenshot

2. Unit tests

npx vitest run src/gemini.test.tsx   (packages/cli)
✓ startInteractiveUI > disables the Kitty keyboard protocol only after Ink has unmounted  (new)
Tests  42 passed (42)

3. Static checks

  • npm run typecheck — passes across all workspaces
  • npx eslint / npx prettier --check on both changed files — clean

Behavior notes for reviewers

  • The change is a two-line reorder in the exit cleanup (pop moved after instance.unmount()) plus the comment explaining the per-screen semantics; the process.on('exit'/'SIGINT'/'SIGTERM') fallbacks in kittyProtocolDetector.ts are untouched.
  • The original report mentions rapid Ctrl-C on Linux/Ghostty with (presumably) default config; on current main the PTY harness shows the default-config path already emits the pop correctly, and the follow-up comment ("also happens with plain /quit") is fully explained by the alternate-screen ordering fixed here.
中文版本

验证报告

所有验证均在本分支本地完成(基线:30984a2f5)。

1. E2E 复现 — PTY 中运行真实 CLI,非 mock

Python harness 在伪终端中 fork 真实打包产物(node dist/cli.js),模拟支持 kitty 协议的终端:应答探测查询(ESC[?uESC[?1uESC[cESC[?62c)使 CLI 真正启用协议,随后驱动双 Ctrl-C 退出并捕获应用写出的每个字节,分析 push(ESC[>1u)、pop(ESC[<u)与进出备屏(ESC[?1049h/l)的字节偏移顺序:

  • 未打补丁 + useTerminalBuffer: true:push@14 → 进备屏@41 → pop@33919 → 离开备屏@33945 —— ❌ pop 写在备屏内,主屏 flags 永不清除
  • 本 PR + useTerminalBuffer: true:push@14 → 进备屏@41离开备屏@22593 → pop@22613 —— ✅ pop 落在主屏
  • 本 PR + 默认配置:push@14 → pop@18508 —— ✅ 行为不变

由于 kitty 规范按屏幕独立跟踪键盘 flags,未打补丁的顺序意味着进程退出后主屏 flags=1 仍生效——shell 收到 9;5u 这类 kitty 编码,与 issue 症状及报告人的 printf '\e[<u' 临时解法完全吻合。

2. 单元测试

gemini.test.tsx 42/42 通过,含新增回归用例「仅在 Ink unmount 之后禁用 Kitty 键盘协议」。

3. 静态检查

npm run typecheck / eslint / prettier 全部通过。

审阅要点

  • 改动本质是退出清理中的两行顺序调整(pop 移到 instance.unmount() 之后)加解释 per-screen 语义的注释;kittyProtocolDetector.ts 中的 exit/SIGINT/SIGTERM 兜底未动。
  • 原报告提到 Linux/Ghostty 上快速 Ctrl-C(推测为默认配置);当前 main 上 PTY 实测默认路径已能正确发出 pop,而后续评论「干净 /quit 也复现」由本 PR 修复的备屏顺序问题完整解释。

🤖 Generated with Claude Code

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed bug with strong evidence. #6776 reports garbled terminal after Ctrl-C exit, and the PR includes a PTY harness that captures byte-level ordering of the push/pop/alt-screen-off sequences — confirming the pop lands inside the alternate screen. The linked issue is open with a clear reproduction.

Direction: aligned. Terminal state restoration on exit is a core CLI responsibility, and the kitty keyboard protocol ordering is a well-understood spec requirement (per-screen flag tracking). No CHANGELOG reference, but this is squarely within CLI output hygiene.

Size: not applicable — changes are in packages/cli, not core modules. Production logic: 8 additions + 4 deletions = 12 lines. Test: 40 additions.

Approach: minimal and focused. The fix is a single-line reordering — disableKittyProtocol() moves from before instance.unmount() to after it. The new comment explains the per-screen flag tracking clearly. The regression test uses invocationCallOrder to enforce the ordering constraint. No scope creep, no drive-by refactors.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:已观测到的 bug,有充分证据。#6776 报告了 Ctrl-C 退出后终端乱码,PR 包含一个 PTY harness,在字节级别捕获了 push/pop/离开备屏序列的顺序——确认 pop 落在了备用屏幕内。关联 issue 处于 open 状态,复现清晰。

方向:对齐。退出时的终端状态恢复是 CLI 的核心职责,kitty 键盘协议的顺序是规范中明确的要求(按屏幕独立跟踪 flags)。CHANGELOG 中没有直接引用,但这完全属于 CLI 输出规范范畴。

规模:不适用——改动在 packages/cli,不涉及核心模块。生产逻辑:8 行增加 + 4 行删除 = 12 行。测试:40 行增加。

方案:最小化且聚焦。修复是一行重排——disableKittyProtocol()instance.unmount() 之前移到之后。新注释清晰解释了按屏幕 flag 跟踪的原因。回归测试使用 invocationCallOrder 强制执行顺序约束。无范围蔓延,无顺手重构。

进入代码审查 🔍

Qwen Code · qwen3.7-max

Reviewed at 9abeab66145b484028ca911f1e850ddfa97715f1 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: the protocol push (ESC[>1u) happens on the main screen in detectAndEnableKittyProtocol() before Ink renders. With useTerminalBuffer: true, Ink then enters the alternate screen. The pop must be written after leaving the alternate screen — so move disableKittyProtocol() to after instance.unmount(). Verify disableKittyProtocol() is idempotent (it is — disableProtocol() checks protocolEnabled and sets it to false, so the process exit/SIGINT/SIGTERM handlers that also call it are harmless no-ops).

The PR's approach matches this exactly. The production change is 1 line of logic (reordering disableKittyProtocol() and instance.unmount()) plus an updated comment that explains the per-screen flag tracking. No correctness issues, no security concerns, no AGENTS.md violations.

Reuse-before-new-code: not applicable — no new logic is introduced.

Test Results

Applied the PR diff locally and ran the full test suite from packages/cli:

 ✓ startInteractiveUI > disables the Kitty keyboard Protocol only after Ink has unmounted 1ms
 ✓ startInteractiveUI > periodic memory-pressure check > runs performCheck on an interval without any tool calls 5ms
 ✓ startInteractiveUI > periodic memory-pressure check > clears the interval and runs a final check before unmount 1ms

 Test Files  1 passed (1)
      Tests  42 passed (42)
   Duration  20.64s

All 42 tests pass, including the new regression test. The regression test uses vi.mocked(disableKittyProtocol).mock.invocationCallOrder[0] vs unmount.mock.invocationCallOrder[0] — a robust way to enforce the ordering constraint without mocking the internals.

Real-Scenario Testing

tmux testing is not applicable for this fix: the bug is about escape sequence ordering in the output stream (kitty keyboard protocol per-screen flag tracking), not a visual TUI change. tmux does not implement the kitty keyboard protocol, so the bug cannot be reproduced in a tmux session. The PR's PTY harness — which runs the real bundled CLI inside a pseudo-terminal that answers kitty protocol detection probes — provides stronger evidence than tmux could: byte-level ordering proof showing push@14 → alt-on@41 → alt-off@22593 → pop@22613 with this PR (vs push@14 → alt-on@41 → pop@33919 → alt-off@33945 on unpatched main).

Note: the pre-existing typecheck errors in packages/cli/src/serve/workspace-service/ (missing @qwen-code/acp-bridge declarations) and the npm run dev failure (unbuilt acp-bridge dist) are unrelated to this PR — they exist on the main branch.

中文说明

代码审查

独立方案:协议的 push(ESC[>1u)发生在主屏幕上的 detectAndEnableKittyProtocol() 中,在 Ink 渲染之前。启用 useTerminalBuffer: true 时,Ink 随后进入备用屏幕。pop 必须在离开备用屏幕之后写入——因此将 disableKittyProtocol() 移到 instance.unmount() 之后。验证 disableKittyProtocol() 是幂等的(确实如此——disableProtocol() 检查 protocolEnabled 并设为 false,所以 process exit/SIGINT/SIGTERM 处理函数再次调用也是无害的空操作)。

PR 的方案与此完全一致。生产代码改动是 1 行逻辑(重排 disableKittyProtocol()instance.unmount() 的顺序)加一个更新的注释,解释了按屏幕 flag 跟踪的原因。无正确性问题、无安全顾虑、无 AGENTS.md 违规。

测试结果

本地应用 PR diff 后从 packages/cli 运行完整测试套件:42/42 全部通过,包括新增的回归测试。回归测试使用 invocationCallOrder 对比——一种稳健的方式在不 mock 内部实现的情况下强制执行顺序约束。

真实场景测试

tmux 测试不适用于此修复:bug 是关于输出流中转义序列的顺序(kitty 键盘协议按屏幕 flag 跟踪),而非可视化 TUI 变更。tmux 不实现 kitty 键盘协议,无法在 tmux 会话中复现此 bug。PR 的 PTY harness——在应答 kitty 协议检测探测的伪终端中运行真实打包产物——提供了比 tmux 更强的证据:字节级别的顺序证明,显示 push@14 → alt-on@41 → alt-off@22593 → pop@22613(而 main 上是 push@14 → alt-on@41 → pop@33919 → alt-off@33945)。

注:packages/cli/src/serve/workspace-service/ 中预先存在的 typecheck 错误(缺少 @qwen-code/acp-bridge 声明)和 npm run dev 失败(未构建的 acp-bridge dist)与此 PR 无关——它们在 main 分支上就存在。

Qwen Code · qwen3.7-max

Reviewed at 9abeab66145b484028ca911f1e850ddfa97715f1 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — clean, minimal fix for a documented bug with byte-level evidence and a solid regression test.

This is exactly the kind of PR the gate exists to let through: a focused, one-line reorder that solves a real user-facing problem (terminal corruption on exit in kitty-protocol terminals), backed by a PTY harness that proves the fix at the byte level, and a regression test that locks in the ordering constraint. The comment on the moved line explains the why clearly — per-screen flag tracking in the kitty spec — so future maintainers won't need to re-derive the reasoning. disableKittyProtocol() is idempotent, so the signal-based exit handlers remain safe no-ops. No scope creep, no drive-by refactors.

Approving. ✅

中文说明

信心度:5/5 — 干净、最小化的修复,针对一个有文档记录的 bug,有字节级证据和可靠的回归测试。

这正是 gate 存在的意义——让这类 PR 通过:聚焦的单行重排,解决真实的用户问题(kitty 协议终端退出后终端乱码),有 PTY harness 在字节级别证明修复有效,回归测试锁定了顺序约束。移动行的注释清晰解释了原因——kitty 规范中的按屏幕 flag 跟踪——未来的维护者无需重新推导。disableKittyProtocol() 是幂等的,信号退出处理函数保持安全的空操作。无范围蔓延,无顺手重构。

批准 ✅

Qwen Code · qwen3.7-max

Reviewed at 9abeab66145b484028ca911f1e850ddfa97715f1 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

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.

LGTM, looks ready to ship. ✅

@qwen-code-ci-bot qwen-code-ci-bot left a comment

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.

⚠️ Downgraded from Approve to Comment: CI still running. Reviewed.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Independent Verification Report (local build + real E2E)

Independently verified at head 9abeab661 on macOS (Darwin 24.6) / Node v22, in an isolated worktree with a full npm ci and a separately written PTY harness (node-pty, not the author's Python one), driving the real esbuild bundle dist/cli.js — no mocks.

Verdict: the fix is confirmed end-to-end and is structurally sound, not timing-dependent. LGTM — ready to merge.

1 · A/B byte-order E2E (real bundle in a PTY)

The harness emulates a kitty-protocol terminal (answers the CLI's ESC[?u / ESC[c probes), waits for the UI, drives a double-Ctrl-C exit, and indexes every control sequence in the captured output. Single-variable experiment: the PR touches neither package.json nor package-lock.json, so both builds share the identical dependency tree; only the checked-out commit differs.

run build useTerminalBuffer observed order verdict
base-vp 30984a2f5 (merge-base) true push@7 → alt-on@182 → pop@63679 → alt-off@63714 ❌ pop inside alt screen — #6776 reproduced
pr-vp (×2) 9abeab661 true push@7 → alt-on@182 → alt-off@51640 → pop@51660 ✅ pop lands on the main screen
pr-novp 9abeab661 false push@7 → pop@13007, no 1049h/l ✅ default config unchanged
base-novp 30984a2f5 false push@7 → pop@14669, no 1049h/l ✅ default was already correct pre-PR

Matches the author's numbers in shape exactly, from an independent harness. Bundle provenance was checked before driving: the PR chunk startInteractiveUI-6FSOJ4HJ.js contains instance.unmount(); disableKittyProtocol(); while the baseline chunk has the reverse order.

A/B byte-order E2E

2 · Unit tests + mutation check of the new regression test

  • packages/clinpx vitest run src/gemini.test.tsx at PR head: 42/42 pass (isolated $HOME).
  • Mutation check: temporarily reverting the source to the old order (disableKittyProtocol() before instance.unmount()) makes the new test fail with AssertionError: expected 5 to be greater than 6 — the test genuinely guards this fix, it is not vacuously green. (Source restored afterwards.)

unit tests + mutation check

3 · Why the fix is structural, not lucky timing

In the patched ink 7.0.3 actually shipped (patches/ink+7.0.3.patch), unmount() calls finishUnmount() synchronously before returning, and that is where ESC[?1049l (exitAlternativeScreen) is written. So by the time instance.unmount() returns in the cleanup chain, the alternate screen has already been left — the pop that follows is guaranteed to land on the main screen.

Two more review checks that came back clean:

  • No regression of fix(cli): disable Kitty keyboard protocol on SIGINT to prevent garbled 9;5u output #3544 (2aad7c061), the commit that originally placed disableKittyProtocol() before unmount: its goal was only "write the pop during cleanup while stdout is a live TTY". The new position is still inside the same synchronous cleanup chain, stdout is still a writable TTY after unmount, and the interactive quit path awaits runExitCleanup() before process.exit(0).
  • The "permanent" symptom is explained by the code: disableProtocol() is guarded by protocolEnabled, so once the old code wasted the pop inside the alt screen, the flag went false and the process.on('exit'/'SIGINT'/'SIGTERM') fallbacks became no-ops — the main screen's flags could never be cleared afterwards. Those fallbacks are untouched by this PR and still cover abnormal exits.

4 · Mergeability

git merge-tree merges clean onto current main (3e003b018). The only change to the two touched files on main since the merge-base is the unrelated test-isolation commit e68d703ce — no semantic overlap.

Not covered here

Same scope limits the PR itself states: Linux/Windows not re-verified locally (the mechanism is platform-independent escape-sequence ordering), and abnormal terminations that never reach the cleanup chain while the alt screen is active remain a pre-existing broader issue.

中文版本(Chinese version)

独立验证报告(本地构建 + 真实 E2E)

在隔离 worktree 中于 head 9abeab661 完成独立验证:macOS(Darwin 24.6)/ Node v22,完整 npm ci,并使用独立编写的 PTY harness(node-pty,非作者的 Python 版本)驱动真实 esbuild 产物 dist/cli.js,全程无 mock。

结论:修复端到端确认有效,且在结构上有保证、不依赖时序运气。LGTM,可以合并。

1 · A/B 字节序 E2E(PTY 中驱动真实产物)

harness 模拟 kitty 协议终端(应答 CLI 的 ESC[?u / ESC[c 探测),等待 UI 就绪后驱动双 Ctrl-C 退出,并对捕获输出中的每个控制序列定位索引。单变量对照:PR 未改动 package.json / package-lock.json,两个构建依赖树完全一致,仅 checkout 的提交不同。

运行 构建 useTerminalBuffer 观测顺序 结论
base-vp 30984a2f5(merge-base) true push@7 → alt-on@182 → pop@63679 → alt-off@63714 ❌ pop 落在备屏内,复现 #6776
pr-vp(×2) 9abeab661 true push@7 → alt-on@182 → alt-off@51640 → pop@51660 ✅ pop 落在主屏
pr-novp 9abeab661 false push@7 → pop@13007,无 1049h/l ✅ 默认配置行为不变
base-novp 30984a2f5 false push@7 → pop@14669,无 1049h/l ✅ 修复前默认配置本就正确

与作者报告的形态完全一致,且来自独立实现的 harness。驱动前已核验产物来源:PR chunk startInteractiveUI-6FSOJ4HJ.js 中为 instance.unmount(); disableKittyProtocol();,基线 chunk 为相反顺序。

2 · 单元测试 + 新回归测试的突变检验

  • packages/cli → PR head 上 npx vitest run src/gemini.test.tsx:42/42 通过(隔离 $HOME)。
  • 突变检验:临时把源码换回旧顺序(disableKittyProtocol()instance.unmount() 之前),新测试如预期失败:AssertionError: expected 5 to be greater than 6——测试真实守护此修复,并非空转变绿。(随后已还原源码。)

3 · 为什么修复是结构性的、而非时序运气

实际发布使用的补丁版 ink 7.0.3(patches/ink+7.0.3.patch)中,unmount() 在返回前同步调用 finishUnmount(),ESC[?1049l(退出备屏)正是在那里写出的。因此清理链中 instance.unmount() 返回时备屏已经退出,随后的 pop 必然落在主屏。

另外两项审查检查均无问题:

  • 不会回归 fix(cli): disable Kitty keyboard protocol on SIGINT to prevent garbled 9;5u output #3544(2aad7c061,当年把 disableKittyProtocol() 放在 unmount 之前的提交):其目标只是"在清理期间、stdout 仍为可用 TTY 时写出 pop"。新位置仍在同一同步清理链内,unmount 后 stdout 仍是可写 TTY,交互式退出路径也会先 await runExitCleanup()process.exit(0)
  • "永久残留"症状与代码吻合:disableProtocol()protocolEnabled 标志守卫,旧代码在备屏内浪费掉 pop 后标志清零,process.on('exit'/'SIGINT'/'SIGTERM') 兜底全部变为空操作——主屏 flags 此后无法被清除。这些兜底逻辑本 PR 未改动,异常退出仍有覆盖。

4 · 可合并性

git merge-tree 对当前 main(3e003b018)合并无冲突。自 merge-base 以来 main 上对这两个文件的唯一改动是无关的测试隔离提交 e68d703ce,无语义重叠。

未覆盖范围

与 PR 自述一致:未在 Linux/Windows 本地复验(机制为平台无关的转义序列顺序);备屏激活期间未进入清理链的异常终止属既有更大问题,不在本 PR 范围内。

@wenshao
wenshao added this pull request to the merge queue Jul 17, 2026
Merged via the queue into QwenLM:main with commit f58d112 Jul 17, 2026
51 of 52 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

When using Ctrl-C to exit can end up with garbled terminal on certain keypresses

3 participants