Skip to content

Commit fda0fa1

Browse files
authored
fix: handle terminal focus events globally (#713)
1 parent ac1d252 commit fda0fa1

File tree

3 files changed

+91
-6
lines changed

3 files changed

+91
-6
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Global Terminal Focus Event Handler
2+
3+
**Date:** 2025-01-26
4+
5+
## Context
6+
7+
When terminal focus reporting is enabled via `\x1b[?1004h`, terminals send escape sequences `\x1b[I` (focus gained) and `\x1b[O` (focus lost) to indicate window focus changes. Ink strips the `\x1b` prefix, leaving `[I` and `[O` as input strings.
8+
9+
The issue was that these escape sequences were appearing as literal text `[I` in the chat input field, particularly when clicking/focusing the terminal window while a modal (approval, ask question, fork) was displayed.
10+
11+
## Discussion
12+
13+
**Root Cause Analysis:**
14+
15+
1. Focus reporting is enabled globally in `ChatInput.tsx`:
16+
```tsx
17+
process.stdout.write('\x1b[?1004h');
18+
```
19+
20+
2. The handler for `[I`/`[O` was inside `TextInput`'s `wrappedOnInput` function, passed to `useInput`:
21+
```tsx
22+
useInput(wrappedOnInput, { isActive: focus });
23+
```
24+
25+
3. When `focus={false}` (during modals), the `useInput` hook is inactive, so `wrappedOnInput` never runs.
26+
27+
4. The unhandled escape sequences leak through and get inserted as literal text.
28+
29+
**Reproduction Scenarios:**
30+
- Approval modal is open
31+
- AskQuestionModal is showing
32+
- ForkModal is active
33+
- Any state where `TextInput` has `focus={false}`
34+
35+
...and then clicking/focusing the terminal window.
36+
37+
**Fix Options Considered:**
38+
39+
1. **Move focus tracking to a global level** - Handle `[I`/`[O` in a separate always-active `useInput` hook
40+
2. **Filter at stdin level** - Strip these sequences before they reach any input handler
41+
42+
Option 1 was chosen as it's simpler and follows the existing pattern used in `AskQuestionModal.tsx`.
43+
44+
## Approach
45+
46+
Add a global always-active `useInput` hook in `ChatInput.tsx` (where focus reporting is enabled) to intercept focus events regardless of which component has focus. Keep a simplified handler in `TextInput` as a safety net.
47+
48+
## Architecture
49+
50+
**Changes to `ChatInput.tsx`:**
51+
- Import `useInput` from ink
52+
- Add global focus event handler with `isActive: true`:
53+
```tsx
54+
useInput(
55+
(input) => {
56+
if (input === '[I' || input === '[O') {
57+
useAppStore.getState().setWindowFocused(input === '[I');
58+
}
59+
},
60+
{ isActive: true },
61+
);
62+
```
63+
64+
**Changes to `TextInput/index.tsx`:**
65+
- Simplify the focus event handling to just skip the sequences:
66+
```tsx
67+
if (input === '[I' || input === '[O') {
68+
return;
69+
}
70+
```
71+
- Remove unused `useAppStore` import
72+
73+
**Existing handlers:**
74+
- `AskQuestionModal.tsx` already has its own focus handler with `isActive: true`, providing coverage when that modal is rendered
75+
- The global handler in `ChatInput.tsx` ensures coverage for all other cases (approval modal, fork modal, etc.)
76+
77+
**Key Principle:** Focus reporting is enabled in `ChatInput.tsx`, so the handler should also be in `ChatInput.tsx` with `isActive: true` to ensure the events are always caught.

src/ui/ChatInput.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Box, Text } from 'ink';
1+
import { Box, Text, useInput } from 'ink';
22
import os from 'os';
33
import { useCallback, useEffect, useMemo, useState } from 'react';
44
import { SPACING, UI_COLORS } from './constants';
@@ -47,6 +47,17 @@ export function ChatInput() {
4747
};
4848
}, []);
4949

50+
// Global handler for terminal focus events - always active to catch focus
51+
// events even when TextInput is not focused (e.g., during modals)
52+
useInput(
53+
(input) => {
54+
if (input === '[I' || input === '[O') {
55+
useAppStore.getState().setWindowFocused(input === '[I');
56+
}
57+
},
58+
{ isActive: true },
59+
);
60+
5061
// Memoize platform-specific modifier key to avoid repeated os.platform() calls
5162
const modifierKey = useMemo(
5263
() => (os.platform() === 'darwin' ? 'option+up' : 'alt+up'),

src/ui/TextInput/index.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { existsSync } from 'fs';
33
import { type Key, Text, useInput } from 'ink';
44
import React from 'react';
55
import { PASTE_CONFIG } from '../constants';
6-
import { useAppStore } from '../store';
76
import { useTerminalSize } from '../useTerminalSize';
87
import { darkTheme } from './constant';
98
import { useTextInput } from './hooks/useTextInput';
@@ -442,11 +441,9 @@ export default function TextInput({
442441
};
443442

444443
const wrappedOnInput = (input: string, key: Key): void => {
445-
// Terminal focus tracking: when enabled via \x1b[?1004h, terminals send
446-
// \x1b[I (focus gained) and \x1b[O (focus lost). Ink strips the \x1b prefix,
447-
// leaving '[I' and '[O' which we intercept to update focus state.
444+
// Terminal focus events ([I/[O] are handled globally in ChatInput.tsx
445+
// Skip them here to prevent any leakage into input
448446
if (input === '[I' || input === '[O') {
449-
useAppStore.getState().setWindowFocused(input === '[I');
450447
return;
451448
}
452449

0 commit comments

Comments
 (0)