Skip to content

Commit e86573a

Browse files
fix: 修复 -r 模式下键盘输入无响应
两个根因: 1. earlyInput 的 readableHandler 残留在 stdin 上 setAppCallbacks() 在反编译项目中从未被调用,导致 stopCapturingEarlyInput() 是 no-op,readableHandler 在 Ink 的 handleReadable 之前消费所有 stdin 数据。 修复:在 handleSetRawMode(true) 时移除非自身的 readable listeners。 2. React 19 layout effect cleanup 顺序问题 React 19 先运行新树的 layout effects,再清理旧树。 当旧树(showSetupDialog)比新树(launchResumeChooser) 有更多 useInput hooks 时,旧树 cleanup 把 rawModeEnabledCount 降到 0,错误关闭 raw mode。 修复:当 count=0 但仍有活跃 EventEmitter listeners 时恢复 count。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3e1c6bc commit e86573a

2 files changed

Lines changed: 26 additions & 3 deletions

File tree

packages/@ant/ink/src/components/App.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,9 +315,21 @@ export default class App extends PureComponent<Props, State> {
315315
if (this.rawModeEnabledCount === 0) {
316316
// Stop early input capture right before we add our own readable handler.
317317
// Both use the same stdin 'readable' + read() pattern, so they can't
318-
// coexist -- our handler would drain stdin before Ink's can see it.
319-
// The buffered text is preserved for REPL.tsx via consumeEarlyInput().
318+
// coexist -- the early capture handler would drain stdin before ours
319+
// can see it. The buffered text is preserved for REPL.tsx via consumeEarlyInput().
320320
defaultCallbacks.stopCapturingEarlyInput()
321+
322+
// Safety net: remove any pre-existing readable listeners that aren't
323+
// ours. In builds where setAppCallbacks() was never called, the early
324+
// input capture's readableHandler remains attached and would consume
325+
// all stdin data before our handleReadable sees it.
326+
const existingListeners = stdin.listeners('readable')
327+
for (const listener of existingListeners) {
328+
if (listener !== this.handleReadable) {
329+
stdin.removeListener('readable', listener as any)
330+
}
331+
}
332+
321333
stdin.ref()
322334
stdin.setRawMode(true)
323335
stdin.addListener('readable', this.handleReadable)
@@ -363,6 +375,17 @@ export default class App extends PureComponent<Props, State> {
363375

364376
// Disable raw mode only when no components left that are using it
365377
if (--this.rawModeEnabledCount === 0) {
378+
// Guard: React 19 runs new useLayoutEffect setup before old cleanup when
379+
// replacing the tree (e.g., showSetupDialog → launchResumeChooser).
380+
// If the old tree had more useInput hooks than the new tree, the old
381+
// cleanup over-decrements the count to 0 even though the new tree has
382+
// active listeners. Detect this and fix the count instead of disabling.
383+
const activeListeners = this.internal_eventEmitter.listenerCount('input')
384+
if (activeListeners > 0) {
385+
this.rawModeEnabledCount = activeListeners
386+
return
387+
}
388+
366389
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS)
367390
this.props.stdout.write(DISABLE_KITTY_KEYBOARD)
368391
// Disable terminal focus reporting (DECSET 1004)

src/dialogLaunchers.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ export async function launchTeleportRepoMismatchDialog(
168168

169169
/**
170170
* Site ~4903: ResumeConversation mount (interactive session picker).
171-
* Uses renderAndRun, NOT showSetupDialog. Wraps in <App><KeybindingSetup>.
171+
* Wraps in <App><KeybindingSetup> and uses renderAndRun.
172172
* Preserves original Promise.all parallelism between getWorktreePaths and imports.
173173
*/
174174
export async function launchResumeChooser(

0 commit comments

Comments
 (0)