Fix Shift+Enter newline and initial PTY size race in terminal#156
Fix Shift+Enter newline and initial PTY size race in terminal#156spamsch wants to merge 10 commits into
Conversation
- Intercept Shift+Enter in xterm's key handler and write \n to the PTY instead of letting xterm send \r. Claude Code maps \n to chat:newline (same as Ctrl+J), so the prompt grows rather than submitting. - Fire the first PTY resize immediately on mount instead of waiting the 80ms debounce. The PTY starts at the 120×24 snapshot default; without this, Claude Code renders its initial Ink frame at the wrong dimensions and requires Ctrl+L to redraw correctly. Pattern matches Hyper, which calls fitAddon.fit() synchronously in componentDidMount then debounces all subsequent ResizeObserver-triggered resizes.
|
Good catch I miss this, About the checks, are failing because the tests still expect the old resize behavior. This PR now sends the first terminal resize immediately, but
Both now receive an immediate Best |
- Reflect that the first PTY resize now fires immediately on mount (no 80ms debounce): update test 1 to expect resize after flushPromises rather than after timer advance; verify no second call after the rAF. - Update the noisy-resize coalescing test: initial resize fires immediately, then the three same-size ResizeObserver callbacks are absorbed by the dedup check rather than the debounce timer. Final call count is still 1 and no extra timer is queued. - Expose triggerKeyEvent on the xterm mock (stores the handler from attachCustomKeyEventHandler instead of ignoring it) and add it to XtermMockInstance type. - New test: Shift+Enter keydown writes \n and returns false; plain Enter and Shift+Enter keyup are not intercepted.
Once rawOutput crosses MAX_RAW_OUTPUT_CHARS, appendTerminalRawOutput trims the front on every subsequent chunk. The viewport saw the non-prefix result as a genuine content mismatch and called terminal.reset() + full 2MB rewrite on every single write — producing a blank-flash loop that never stopped. Fix: detect the truncation case (rawOutput didn't shrink — only the front was sliced) and recover the new tail via a 32-byte probe + 32-byte verification into the previous rawOutput, then write only the new bytes. Falls back to reset+rewrite for genuine mismatches or bursts > 512KB. MAX_SEARCH bumped to 512KB (was 100KB) to cover coalesced PTY bursts (paste echo, large file cat, TUI redraws). False-positive comment softened — 64 bytes reduces but doesn't eliminate repeated-ANSI-sequence collisions; a wrong delta produces one correctable garbled frame, not a loop. Follow-up: surface trimmedFront from appendTerminalRawOutput so the delta is computed arithmetically instead of via string search (no heuristics). Tests: assert terminal.reset() is not called on truncation, and that the final tail bytes reach xterm even when output stops at the trim boundary.
|
Thanks! Next time, if a PR isn’t finished yet, just let me know to wrap it up, I can’t really tell on my end whether you’re done or not. ;) |
|
I reviewed the code by hand because this is delicate, non of them are blockers, but worth discussing, thanks for marking these as a draft, sorry for changing that 😅 Shift+Enter is applied globally Missing 32-char probe can collide in ANSI-heavy output Chunks > 512KB fall through to full reset Let me know your thoughts, Thanks, |
Consistent with the adjacent Cmd+F and Escape branches; also hardens against the xterm hidden textarea inserting a stray LF independently of the return false path.
|
Shift+Enter scope: intentional. Readline-based shells treat Probe collisions: acknowledged and documented in the code. A false positive produces one correctable garbled frame, not a loop — acceptable for a stopgap. The follow-up to surface 512KB cutoff: accepted trade-off, already noted in the inline comment. |
|
Also looked at how Hyper handles this. Short answer: it sidesteps the problem entirely by never maintaining a string replay buffer. PTY data flows straight into the live xterm instance via a Redux middleware and is discarded by the app layer — the store holds session metadata only, no output. Inactive tabs are parked offscreen at The trade-off is Hyper makes none of the promises NeverWrite makes: no persistence across reloads, scrollback capped at 1000 lines (xterm's own limit), everything lost on window close. The |
|
Thank you for the detailed response. It was very useful, I had to learn quite a bit about terminals to fully understand it. My initial implementation was very rough, so I really appreciate all the help. The Hyper comparison is helpful too. I agree it is not a drop-in model for NeverWrite, since we intentionally keep My only remaining concern is the truncation probe. I agree the heuristic is acceptable as a short-term stopgap, but I’m not fully convinced the worst case is limited to a single garbled frame. If the probe matches the wrong repeated region, we could compute the wrong delta, write too little of the new tail, update I’d be comfortable merging this if either:
No blocker on the Shift+Enter part from my side. I just want to be rigorous about the truncation edge case, but if you think is good enough, I'll merge as is. |
|
You're right to push on this — my "one garbled frame" description undersells the risk. If the probe matches the wrong position, the delta arithmetic is wrong, Given that, I'd go with the regression test. A test with a repeated or ANSI-heavy tail would pin the probe behavior concretely and catch any regression if the heuristic ever gets tuned. The I'll add a test that covers:
If the test passes and it accurately describes the current behavior, that gives us a baseline to validate the |
|
I deleted my previous message, I read to fast your message, I'll wait for the test before closing this PR. I want to push another update today to include Opus 4.8 support. Thank you ;) |
|
Tests added and pushed (69b6f74). Two cases: repeated-content overlap with correct probe hit (no reset, exact delta written); repeated-content with early false-positive hit (verification rejects, falls to reset, no silent drop). Assertions use |
Two new cases: probe finds correct offset when overlap is plain repeated characters; probe hits wrong earlier position and verification rejects it, confirming no silent output drop (falls through to reset + full replay).
|
Found another bug when removing lines. The bottom part does not redraw and leaves artifacts. Checking. |
event.preventDefault() doesn't reliably suppress Electron's hidden textarea from emitting a second \n through onData after the explicit write in the custom key handler. Each Shift+Enter was reaching the PTY twice; Claude Code's Ink prompt grew by N rows but tracked N/2 internally, leaving blank ghost rows on screen when lines were deleted. Fix: set a flag after the explicit write and drop the next \n that arrives through onData while the flag is set.
|
Gooood catch, I missed that one. I found one more issue while reviewing the immediate initial resize path.
When the real So the issue is not the Shift+Enter ghost-row fix; it is specifically the initial resize bookkeeping. I think the fix should either scope/reset |
|
Superseded by the xterm direct-pipe rewrite. The Shift+Enter and immediate-resize fixes from here are already on |
|
Thank you SImon :) |
Summary
Shift+Enter inserts a newline in Claude Code's prompt instead of submitting. xterm.js has no built-in Shift+Enter binding and sent
\r(carriage return) for both Enter and Shift+Enter — identical to submit. We now intercept the keydown inattachCustomKeyEventHandler, write\nto the PTY (which Claude Code maps tochat:newline, same as Ctrl+J), and returnfalseto suppress xterm's default.Initial PTY resize fires immediately instead of waiting the 80ms settle debounce. The PTY starts at the
120×24snapshot default; Claude Code's Ink UI renders its first frame against those dimensions. With the debounce, the correct pixel-calculated size doesn't reach the PTY for at least 80ms, so Claude Code's initial render is wrong and requires a Ctrl+L to fix. Skipping the debounce on the first fit (whenlastRequestedSizeRef.current === null) matches how Hyper handlescomponentDidMount— synchronous first fit, debounced everything after.Upstream tracking issue for the Ink cursor-tracking desync that underlies the redraw bug: anthropics/claude-code#62740
Test plan