From 0fac80202ef450a55e6b841d5d44fcafc4884c3c Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:48:38 +0200 Subject: [PATCH] fix(terminal): preserve Korean composition after submitting a command Submit through the native editor action without inserting a newline into the hidden IME buffer. Return keypresses no longer race the text update or submit twice, and stale newline updates request a clear before further composition. --- .../terminal-input.native.test.ts | 44 +++++++++++++++---- .../native-renderer/terminal-input.native.tsx | 29 ++++++------ 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/packages/app/src/terminal/native-renderer/terminal-input.native.test.ts b/packages/app/src/terminal/native-renderer/terminal-input.native.test.ts index 01d3e398d5..cf5ee3e0ab 100644 --- a/packages/app/src/terminal/native-renderer/terminal-input.native.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-input.native.test.ts @@ -40,7 +40,17 @@ describe("native terminal typed input", () => { expect(input.receiveKeyPress("p")).toEqual({ data: "p", shouldClear: false }); expect(input.receiveKeyPress("w")).toEqual({ data: "w", shouldClear: false }); expect(input.receiveKeyPress("d")).toEqual({ data: "d", shouldClear: false }); - expect(input.receiveKeyPress("Enter")).toEqual({ data: "\r", shouldClear: true }); + expect(input.receiveSubmit()).toEqual({ data: "\r", shouldClear: true }); + }); + + it("waits for native submit instead of submitting Return keypresses", () => { + const input = createTerminalTextInputState(); + input.receiveTextChange("한글"); + + for (const key of ["Enter", "Return", "return"]) { + expect(input.receiveKeyPress(key)).toEqual({ data: "", shouldClear: false }); + } + expect(input.receiveSubmit()).toEqual({ data: "\r", shouldClear: true }); }); it("does not duplicate printable keypresses when the native text change also arrives", () => { @@ -97,7 +107,7 @@ describe("native terminal typed input", () => { for (const key of "echo hello") { input.receiveKeyPress(key); } - expect(input.receiveKeyPress("Enter")).toEqual({ data: "\r", shouldClear: true }); + expect(input.receiveSubmit()).toEqual({ data: "\r", shouldClear: true }); input.reset(); @@ -105,17 +115,37 @@ describe("native terminal typed input", () => { expect(input.receiveTextChange("y")).toEqual({ data: "y", shouldClear: false }); }); - it("ignores late newline text change after Return keypress submits", () => { + it("clears a late newline text change without submitting the command again", () => { const input = createTerminalTextInputState(); for (const key of "echo hello") { input.receiveKeyPress(key); } - expect(input.receiveKeyPress("Enter")).toEqual({ data: "\r", shouldClear: true }); + expect(input.receiveSubmit()).toEqual({ data: "\r", shouldClear: true }); input.reset(); - expect(input.receiveTextChange("echo hello\n")).toEqual({ data: "", shouldClear: false }); + expect(input.receiveTextChange("echo hello\n")).toEqual({ data: "", shouldClear: true }); + }); + + it("clears a late submit update before the next Hangul composition starts", () => { + const input = createTerminalTextInputState(); + input.receiveTextChange("echo hello"); + expect(input.receiveSubmit()).toEqual({ data: "\r", shouldClear: true }); + input.reset(); + + // Gboard sends this after the Enter handler clears the native input. + // Leaving it in the editor makes the next ㅎ arrive as "echo hello\nㅎ", + // which is discarded as multiline input instead of starting 한글. + expect(input.receiveTextChange("echo hello\n")).toEqual({ data: "", shouldClear: true }); + input.reset(); + + expect(input.receiveTextChange("ㅎ").data).toBe("ㅎ"); + expect(input.receiveTextChange("하").data).toBe("\x7f하"); + expect(input.receiveTextChange("한").data).toBe("\x7f한"); + expect(input.receiveTextChange("한ㄱ").data).toBe("ㄱ"); + expect(input.receiveTextChange("한그").data).toBe("\x7f그"); + expect(input.receiveTextChange("한글").data).toBe("\x7f글"); }); it("accepts the same long keyboard clipboard item twice", () => { @@ -133,9 +163,7 @@ describe("native terminal typed input", () => { const input = createTerminalTextInputState(); expect(input.receiveKeyPress("Backspace")).toEqual({ data: "\x7f", shouldClear: false }); - expect(input.receiveKeyPress("Enter")).toEqual({ data: "\r", shouldClear: true }); - expect(input.receiveKeyPress("Return")).toEqual({ data: "\r", shouldClear: true }); - expect(input.receiveKeyPress("return")).toEqual({ data: "\r", shouldClear: true }); + expect(input.receiveSubmit()).toEqual({ data: "\r", shouldClear: true }); expect(input.receiveTextChange("hello\n")).toEqual({ data: "", shouldClear: true }); }); diff --git a/packages/app/src/terminal/native-renderer/terminal-input.native.tsx b/packages/app/src/terminal/native-renderer/terminal-input.native.tsx index 4eea8a1da3..372f45041e 100644 --- a/packages/app/src/terminal/native-renderer/terminal-input.native.tsx +++ b/packages/app/src/terminal/native-renderer/terminal-input.native.tsx @@ -23,6 +23,7 @@ export interface TerminalTextInputChange { export interface TerminalTextInputState { receiveKeyPress: (key: string) => TerminalTextInputChange; + receiveSubmit: () => TerminalTextInputChange; receiveTextChange: (text: string) => TerminalTextInputChange; reset: () => void; } @@ -104,7 +105,6 @@ export function resolveTerminalInputFocusRequest(input: { export function createTerminalTextInputState(): TerminalTextInputState { let previousText = ""; - let submittedText: string | null = null; // A swallowed replacement leaves the terminal holding text the buffer no // longer describes. A composition rubout deletes from the terminal, so it // would delete whatever is actually sitting there rather than the character @@ -124,33 +124,24 @@ export function createTerminalTextInputState(): TerminalTextInputState { } return { data: "\x7f", shouldClear: false }; } - if (key === "Enter" || key === "Return" || key === "return") { - submittedText = previousText; - return { data: "\r", shouldClear: true }; - } if (isPrintableKey(key)) { previousText += key; return { data: key, shouldClear: false }; } return { data: "", shouldClear: false }; }, + receiveSubmit(): TerminalTextInputChange { + return { data: "\r", shouldClear: true }; + }, receiveTextChange(text: string): TerminalTextInputChange { - if (submittedText !== null) { - const lateSubmitText = `${submittedText}\n`; - submittedText = null; - if (text === lateSubmitText) { - previousText = ""; - replacementDesynced = false; - return { data: "", shouldClear: false }; - } - } - if (text.length === 0) { previousText = ""; return { data: "", shouldClear: false }; } if (text.includes("\n") || text.includes("\r")) { + // A late submit update can restore the native buffer after Enter + // cleared it. Clear it again before the IME starts its next word. previousText = ""; replacementDesynced = false; return { data: "", shouldClear: true }; @@ -289,6 +280,12 @@ export const TerminalInput = forwardRef [inputState, onInput, resetNativeInput], ); + const handleSubmit = useCallback(() => { + const change = inputState.receiveSubmit(); + onInput?.(change.data); + resetNativeInput(); + }, [inputState, onInput, resetNativeInput]); + const handleKeyPress = useCallback( (event: NativeSyntheticEvent) => { const change = inputState.receiveKeyPress(event.nativeEvent.key); @@ -325,8 +322,10 @@ export const TerminalInput = forwardRef onBlur={handleBlur} onFocus={handleFocus} onKeyPress={handleKeyPress} + onSubmitEditing={handleSubmit} showSoftInputOnFocus={true} spellCheck={false} + submitBehavior="submit" style={inputStyle} testID="terminal-native-input" textContentType="none"