|
| 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. |
0 commit comments