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.
This commit is contained in:
paseo-bot[bot]
2026-10-16 23:48:38 +02:00
parent d152f1529f
commit 0fac80202e
2 changed files with 50 additions and 23 deletions
@@ -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 });
});
@@ -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<TerminalInputHandle, TerminalInputProps>
[inputState, onInput, resetNativeInput],
);
const handleSubmit = useCallback(() => {
const change = inputState.receiveSubmit();
onInput?.(change.data);
resetNativeInput();
}, [inputState, onInput, resetNativeInput]);
const handleKeyPress = useCallback(
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
const change = inputState.receiveKeyPress(event.nativeEvent.key);
@@ -325,8 +322,10 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
onBlur={handleBlur}
onFocus={handleFocus}
onKeyPress={handleKeyPress}
onSubmitEditing={handleSubmit}
showSoftInputOnFocus={true}
spellCheck={false}
submitBehavior="submit"
style={inputStyle}
testID="terminal-native-input"
textContentType="none"