Skip to content

Commit feb76f1

Browse files
authored
feat(ink): auto compat mode for legacy pre-ConPTY Windows consoles (#1299)
* feat: 老旧 Windows 控制台(pre-ConPTY)自动兼容模式 Windows 10 build < 17763(1709/LTSC 等无 ConPTY 的内网机器)上, 老 conhost 的 VT 解析器对增量渲染的行尾 pending-wrap 语义处理有误, 真实光标与虚拟光标漂移,花屏残渣持续累积,直到一次全量重绘才消除。 该问题影响此类机器上的所有终端(VS Code/mintty 走 winpty 抓取的 是同一份损坏的 conhost buffer)。 - 新增 legacyConsole.ts:按 os.release() build 号自动检测, CLAUDE_CODE_LEGACY_CONSOLE=1/0 可强制开/关 - log-update.ts:命中时每约 1 秒用一次全量重绘替换增量 diff, 屏幕持续自愈;非老控制台环境完全不走此路径 - 6 个单元测试(检测逻辑 + 环境变量覆盖 + 缓存行为) * docs: CLAUDE.md 补充老控制台兼容模式说明,修正版本号描述 * feat: 老控制台兼容模式支持强度调节 真机(1709/build 16299)第一轮验证反馈周期性自愈可能不足, 增加两个调节旋钮: - CLAUDE_CODE_LEGACY_CONSOLE=2(或 always):每帧全量重绘, 用于增量 diff 立即花屏、1 秒自愈不够的机器 - CLAUDE_CODE_LEGACY_CONSOLE_RESET_MS:周期模式的重绘间隔, 钳制在 [100, 10000],默认 1000 不变 默认行为不变(自动检测 → periodic 1s)。新增 9 个单元测试 (模式解析、间隔钳制、空串回退、缓存行为)。 * fix: 老控制台花屏根因修复——渲染宽度收窄 1 列绕开 pending-wrap 真机(1709/build 16299)第二轮反馈:周期性全量重绘在跑(可见闪烁) 但花屏范围随重绘扩大。定位到真正根因:老 conhost 对 pending-wrap 的处理是「写满最后一列立即换行」(现代终端为挂起等待),任何顶满 行宽的输出(分隔线、满行文本)都会把真实光标多推一行——增量 diff 和全量重绘同样受害,且每次重绘还把缓冲区多滚几行,旧帧残渣上移, 表现为「越闪越花」。 修复:legacy 模式下 effectiveColumns() 把渲染宽度收窄 1 列 (下限 20),任何一行都不再触碰最后一列,pending-wrap 缺陷路径 从源头绕开;周期重绘保留作为兜底自愈。非 legacy 环境行为完全不变。 新增 effectiveColumns 单元测试;ink.tsx 三处宽度读取统一走该函数。
1 parent b75b814 commit feb76f1

5 files changed

Lines changed: 285 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ bun run docs:dev
8787
- **Lint/Format**: Biome (`biome.json`)。覆盖 `src/``scripts/``packages/` 全项目(含 `packages/@ant/`)。`bun run lint` / `bun run lint:fix` / `bun run format` / `bun run check` / `bun run check:fix`。42 条规则因 decompiled 代码被关闭,仅保留 `recommended` 基线。
8888
- **Pre-commit**: husky + lint-staged。提交时自动对暂存文件执行 `biome check --fix`(TS/JS)和 `biome format --write`(JSON)。
8989
- **CI Lint**: `ci.yml` 在依赖安装后、类型检查前执行 `bunx biome ci .`,lint 或格式化不达标则 CI 失败。
90-
- **Defines**: 集中管理在 `scripts/defines.ts`当前版本 `2.2.1`
90+
- **Defines**: 集中管理在 `scripts/defines.ts`版本号从 `package.json` 读取(不再硬编码)
9191
- **CI**: GitHub Actions — `ci.yml`(lint + 构建 + 测试)、`release-rcs.yml`(RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
9292

9393
### Entry & Bootstrap
@@ -141,6 +141,7 @@ bun run docs:dev
141141

142142
- **`src/ink.ts`** — Ink render wrapper with ThemeProvider injection.
143143
- **`packages/@ant/ink/`** — Custom Ink framework(forked/internal),包含 components、core、hooks、keybindings、theme、utils。注意:不是 `src/ink/`
144+
- **老控制台兼容模式**`packages/@ant/ink/src/core/legacyConsole.ts`:检测 Windows build < 17763(无 ConPTY 的老系统,如 1709/LTSC 内网机器)时自动启用;`log-update.ts` 的渲染循环每约 1 秒(`LEGACY_CONSOLE_RESET_MS`)用一次全量重绘替换增量 diff,自愈老 conhost 的光标漂移花屏。`CLAUDE_CODE_LEGACY_CONSOLE=1`/`=0` 可强制开/关。其他环境完全不走此路径。
144145
- **`src/components/`** — 149 个组件目录/文件,渲染于终端 Ink 环境中。关键组件:
145146
- `App.tsx` — Root provider (AppState, Stats, FpsMetrics)
146147
- `Messages.tsx` / `MessageRow.tsx` — Conversation message rendering
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { afterEach, describe, expect, test } from 'bun:test'
2+
import {
3+
effectiveColumns,
4+
isLegacyWindowsBuild,
5+
isLegacyWindowsConsole,
6+
legacyConsoleMode,
7+
legacyConsoleResetMs,
8+
parseLegacyConsoleMode,
9+
parseLegacyConsoleResetMs,
10+
resetLegacyConsoleCacheForTesting,
11+
} from '../legacyConsole.js'
12+
13+
const savedOverride = process.env.CLAUDE_CODE_LEGACY_CONSOLE
14+
const savedResetMs = process.env.CLAUDE_CODE_LEGACY_CONSOLE_RESET_MS
15+
16+
function restoreEnv(name: string, value: string | undefined): void {
17+
if (value === undefined) {
18+
delete process.env[name]
19+
} else {
20+
process.env[name] = value
21+
}
22+
}
23+
24+
describe('isLegacyWindowsBuild', () => {
25+
test('build below 17763 (pre-ConPTY) is legacy', () => {
26+
expect(isLegacyWindowsBuild('10.0.16299')).toBe(true)
27+
expect(isLegacyWindowsBuild('10.0.14393')).toBe(true)
28+
})
29+
30+
test('build 17763 and newer are not legacy', () => {
31+
expect(isLegacyWindowsBuild('10.0.17763')).toBe(false)
32+
expect(isLegacyWindowsBuild('10.0.19045')).toBe(false)
33+
expect(isLegacyWindowsBuild('10.0.22631')).toBe(false)
34+
})
35+
36+
test('unparseable release strings are not legacy', () => {
37+
expect(isLegacyWindowsBuild('')).toBe(false)
38+
expect(isLegacyWindowsBuild('6.1')).toBe(false)
39+
expect(isLegacyWindowsBuild('10.0.abc')).toBe(false)
40+
})
41+
})
42+
43+
describe('parseLegacyConsoleMode', () => {
44+
test('0 forces off even when auto-detected', () => {
45+
expect(parseLegacyConsoleMode('0', true)).toBe('off')
46+
})
47+
48+
test('1 forces periodic even without auto-detection', () => {
49+
expect(parseLegacyConsoleMode('1', false)).toBe('periodic')
50+
})
51+
52+
test('2 and always select every-frame mode', () => {
53+
expect(parseLegacyConsoleMode('2', false)).toBe('always')
54+
expect(parseLegacyConsoleMode('always', false)).toBe('always')
55+
})
56+
57+
test('no override follows auto-detection', () => {
58+
expect(parseLegacyConsoleMode(undefined, true)).toBe('periodic')
59+
expect(parseLegacyConsoleMode(undefined, false)).toBe('off')
60+
})
61+
62+
test('unknown override values follow auto-detection', () => {
63+
expect(parseLegacyConsoleMode('yes', true)).toBe('periodic')
64+
expect(parseLegacyConsoleMode('yes', false)).toBe('off')
65+
})
66+
})
67+
68+
describe('parseLegacyConsoleResetMs', () => {
69+
test('defaults to 1000 for missing or garbage values', () => {
70+
expect(parseLegacyConsoleResetMs(undefined)).toBe(1000)
71+
expect(parseLegacyConsoleResetMs('')).toBe(1000)
72+
expect(parseLegacyConsoleResetMs('abc')).toBe(1000)
73+
})
74+
75+
test('clamps to [100, 10000] and floors', () => {
76+
expect(parseLegacyConsoleResetMs('50')).toBe(100)
77+
expect(parseLegacyConsoleResetMs('250.9')).toBe(250)
78+
expect(parseLegacyConsoleResetMs('99999')).toBe(10000)
79+
})
80+
})
81+
82+
describe('effectiveColumns', () => {
83+
afterEach(() => {
84+
restoreEnv('CLAUDE_CODE_LEGACY_CONSOLE', savedOverride)
85+
resetLegacyConsoleCacheForTesting()
86+
})
87+
88+
test('passes width through when legacy mode is off', () => {
89+
process.env.CLAUDE_CODE_LEGACY_CONSOLE = '0'
90+
resetLegacyConsoleCacheForTesting()
91+
expect(effectiveColumns(120)).toBe(120)
92+
expect(effectiveColumns(undefined)).toBe(80)
93+
expect(effectiveColumns(0)).toBe(80)
94+
})
95+
96+
test('narrows by one column on legacy consoles (with floor)', () => {
97+
process.env.CLAUDE_CODE_LEGACY_CONSOLE = '1'
98+
resetLegacyConsoleCacheForTesting()
99+
expect(effectiveColumns(120)).toBe(119)
100+
expect(effectiveColumns(undefined)).toBe(79)
101+
expect(effectiveColumns(21)).toBe(20)
102+
expect(effectiveColumns(5)).toBe(20)
103+
})
104+
})
105+
106+
describe('legacyConsoleMode / isLegacyWindowsConsole / legacyConsoleResetMs', () => {
107+
afterEach(() => {
108+
restoreEnv('CLAUDE_CODE_LEGACY_CONSOLE', savedOverride)
109+
restoreEnv('CLAUDE_CODE_LEGACY_CONSOLE_RESET_MS', savedResetMs)
110+
resetLegacyConsoleCacheForTesting()
111+
})
112+
113+
test('CLAUDE_CODE_LEGACY_CONSOLE=1 forces legacy mode on', () => {
114+
process.env.CLAUDE_CODE_LEGACY_CONSOLE = '1'
115+
resetLegacyConsoleCacheForTesting()
116+
expect(isLegacyWindowsConsole()).toBe(true)
117+
expect(legacyConsoleMode()).toBe('periodic')
118+
})
119+
120+
test('CLAUDE_CODE_LEGACY_CONSOLE=0 forces legacy mode off', () => {
121+
process.env.CLAUDE_CODE_LEGACY_CONSOLE = '0'
122+
resetLegacyConsoleCacheForTesting()
123+
expect(isLegacyWindowsConsole()).toBe(false)
124+
expect(legacyConsoleMode()).toBe('off')
125+
})
126+
127+
test('CLAUDE_CODE_LEGACY_CONSOLE=2 selects every-frame mode', () => {
128+
process.env.CLAUDE_CODE_LEGACY_CONSOLE = '2'
129+
resetLegacyConsoleCacheForTesting()
130+
expect(isLegacyWindowsConsole()).toBe(true)
131+
expect(legacyConsoleMode()).toBe('always')
132+
})
133+
134+
test('reset interval is read from env with clamping', () => {
135+
process.env.CLAUDE_CODE_LEGACY_CONSOLE_RESET_MS = '250'
136+
resetLegacyConsoleCacheForTesting()
137+
expect(legacyConsoleResetMs()).toBe(250)
138+
process.env.CLAUDE_CODE_LEGACY_CONSOLE_RESET_MS = '1'
139+
resetLegacyConsoleCacheForTesting()
140+
expect(legacyConsoleResetMs()).toBe(100)
141+
})
142+
143+
test('caches the computed values until reset', () => {
144+
process.env.CLAUDE_CODE_LEGACY_CONSOLE = '1'
145+
process.env.CLAUDE_CODE_LEGACY_CONSOLE_RESET_MS = '500'
146+
resetLegacyConsoleCacheForTesting()
147+
expect(isLegacyWindowsConsole()).toBe(true)
148+
expect(legacyConsoleResetMs()).toBe(500)
149+
process.env.CLAUDE_CODE_LEGACY_CONSOLE = '0'
150+
process.env.CLAUDE_CODE_LEGACY_CONSOLE_RESET_MS = '9000'
151+
expect(isLegacyWindowsConsole()).toBe(true)
152+
expect(legacyConsoleResetMs()).toBe(500)
153+
resetLegacyConsoleCacheForTesting()
154+
expect(isLegacyWindowsConsole()).toBe(false)
155+
expect(legacyConsoleResetMs()).toBe(9000)
156+
})
157+
})

packages/@ant/ink/src/core/ink.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ import {
9393
wrapForMultiplexer,
9494
} from './termio/osc.js';
9595
import { TerminalWriteProvider } from '../hooks/useTerminalNotification.js';
96+
import { effectiveColumns } from './legacyConsole.js';
9697

9798
// Alt-screen: renderer.ts sets cursor.visible = !isTTY || screen.height===0,
9899
// which is always false in alt-screen (TTY + content fills screen).
@@ -247,7 +248,7 @@ export default class Ink {
247248
stderr: options.stderr,
248249
};
249250

250-
this.terminalColumns = options.stdout.columns || 80;
251+
this.terminalColumns = effectiveColumns(options.stdout.columns);
251252
this.terminalRows = options.stdout.rows || 24;
252253
this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows);
253254
this.stylePool = new StylePool();
@@ -396,7 +397,7 @@ export default class Ink {
396397
// blank→paint flicker). useVirtualScroll's height scaling already bounds
397398
// the per-resize cost; synchronous handling keeps dimensions consistent.
398399
private handleResize = () => {
399-
const cols = this.options.stdout.columns || 80;
400+
const cols = effectiveColumns(this.options.stdout.columns);
400401
const rows = this.options.stdout.rows || 24;
401402
// Terminals often emit 2+ resize events for one user action (window
402403
// settling). Same-dimension events are no-ops; skip to avoid redundant
@@ -522,7 +523,7 @@ export default class Ink {
522523
this.options.onBeforeRender?.();
523524

524525
const renderStart = performance.now();
525-
const terminalWidth = this.options.stdout.columns || 80;
526+
const terminalWidth = effectiveColumns(this.options.stdout.columns);
526527
const terminalRows = this.options.stdout.rows || 24;
527528

528529
const frame = this.renderer({
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { release } from 'node:os'
2+
3+
/**
4+
* Legacy Windows console detection (pre-ConPTY).
5+
*
6+
* ConPTY shipped in Windows 10 1809 (build 17763). On older builds the
7+
* conhost VT parser predates it and mis-handles incremental TUI updates
8+
* (pending-wrap semantics at the last column drift the real cursor away
9+
* from the virtual one), so residue accumulates until a full repaint.
10+
* Every terminal on such a machine is affected — VS Code and mintty fall
11+
* back to winpty, which scrapes the same corrupted conhost buffer.
12+
*
13+
* Overrides:
14+
* CLAUDE_CODE_LEGACY_CONSOLE=0 force off
15+
* CLAUDE_CODE_LEGACY_CONSOLE=1 force on (periodic full repaint)
16+
* CLAUDE_CODE_LEGACY_CONSOLE=2|always full repaint on EVERY frame — for
17+
* machines where each incremental diff corrupts immediately and the
18+
* periodic self-heal is not enough. Maximum flicker, maximum
19+
* correctness.
20+
* CLAUDE_CODE_LEGACY_CONSOLE_RESET_MS=<ms>
21+
* periodic repaint interval, clamped to [100, 10000]. Default 1000.
22+
*/
23+
24+
export type LegacyConsoleMode = 'off' | 'periodic' | 'always'
25+
26+
/** Pure build-number check, exported for tests. */
27+
export function isLegacyWindowsBuild(releaseString: string): boolean {
28+
const build = Number(releaseString.split('.')[2])
29+
return Number.isFinite(build) && build < 17763
30+
}
31+
32+
/** Pure override/auto-detect resolution, exported for tests. */
33+
export function parseLegacyConsoleMode(
34+
override: string | undefined,
35+
autoDetected: boolean,
36+
): LegacyConsoleMode {
37+
if (override === '0') return 'off'
38+
if (override === '2' || override === 'always') return 'always'
39+
if (override === '1') return 'periodic'
40+
return autoDetected ? 'periodic' : 'off'
41+
}
42+
43+
/** Pure interval parser with clamping, exported for tests. */
44+
export function parseLegacyConsoleResetMs(raw: string | undefined): number {
45+
// Number('') === 0, so an empty env var must be treated as unset.
46+
if (raw === undefined || raw.trim() === '') return 1000
47+
const parsed = Number(raw)
48+
if (!Number.isFinite(parsed)) return 1000
49+
return Math.min(10_000, Math.max(100, Math.floor(parsed)))
50+
}
51+
52+
let cachedMode: LegacyConsoleMode | undefined
53+
let cachedResetMs: number | undefined
54+
55+
export function legacyConsoleMode(): LegacyConsoleMode {
56+
if (cachedMode === undefined) {
57+
cachedMode = parseLegacyConsoleMode(
58+
process.env.CLAUDE_CODE_LEGACY_CONSOLE,
59+
process.platform === 'win32' && isLegacyWindowsBuild(release()),
60+
)
61+
}
62+
return cachedMode
63+
}
64+
65+
export function isLegacyWindowsConsole(): boolean {
66+
return legacyConsoleMode() !== 'off'
67+
}
68+
69+
export function legacyConsoleResetMs(): number {
70+
if (cachedResetMs === undefined) {
71+
cachedResetMs = parseLegacyConsoleResetMs(
72+
process.env.CLAUDE_CODE_LEGACY_CONSOLE_RESET_MS,
73+
)
74+
}
75+
return cachedResetMs
76+
}
77+
78+
/**
79+
* Effective render width for the terminal.
80+
*
81+
* Legacy conhost mis-handles pending-wrap: writing the last column wraps
82+
* immediately (modern terminals defer the wrap), so every full-width line
83+
* pushes the real cursor one row past where the renderer believes it is —
84+
* both incremental diffs AND full repaints drift, and each full repaint
85+
* scrolls extra lines into view (the "garbage grows with every flash"
86+
* failure mode). Rendering one column narrower means no line ever touches
87+
* the last column, so the buggy code path never fires.
88+
*/
89+
export function effectiveColumns(columns: number | undefined): number {
90+
const cols = columns || 80
91+
if (!isLegacyWindowsConsole()) return cols
92+
return Math.max(20, cols - 1)
93+
}
94+
95+
export function resetLegacyConsoleCacheForTesting(): void {
96+
cachedMode = undefined
97+
cachedResetMs = undefined
98+
}

packages/@ant/ink/src/core/log-update.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ import {
2828
setScrollRegion,
2929
} from './termio/csi.js'
3030
import { LINK_END, link as oscLink } from './termio/osc.js'
31+
import {
32+
isLegacyWindowsConsole,
33+
legacyConsoleMode,
34+
legacyConsoleResetMs,
35+
} from './legacyConsole.js'
3136

3237
type State = {
3338
previousOutput: string
@@ -43,6 +48,8 @@ const NEWLINE = { type: 'stdout', content: '\n' } as const
4348

4449
export class LogUpdate {
4550
private state: State
51+
// Timestamp of the last legacy-console full reset (see render()).
52+
private lastLegacyReset = 0
4653

4754
constructor(private readonly options: Options) {
4855
this.state = {
@@ -147,6 +154,23 @@ export class LogUpdate {
147154
return fullResetSequence_CAUSES_FLICKER(next, 'resize', stylePool)
148155
}
149156

157+
// Legacy Windows console (pre-ConPTY, build < 17763): the old conhost
158+
// VT parser drifts on incremental cursor diffs (pending-wrap semantics
159+
// at the last column), so residue accumulates until a full repaint.
160+
// Replace the diff with a full reset — every frame in 'always' mode
161+
// (machines where each diff corrupts immediately), otherwise on a
162+
// configurable interval so the screen self-heals. Gated off everywhere
163+
// else; see legacyConsole.ts.
164+
if (isLegacyWindowsConsole()) {
165+
if (
166+
legacyConsoleMode() === 'always' ||
167+
startTime - this.lastLegacyReset >= legacyConsoleResetMs()
168+
) {
169+
this.lastLegacyReset = startTime
170+
return fullResetSequence_CAUSES_FLICKER(next, 'clear', stylePool)
171+
}
172+
}
173+
150174
// DECSTBM scroll optimization: when a ScrollBox's scrollTop changed,
151175
// shift content with a hardware scroll (CSI top;bot r + CSI n S/T)
152176
// instead of rewriting the whole scroll region. The shiftRows on

0 commit comments

Comments
 (0)