From 644d66ba4f28777a5a193bd5223a787f5d920f45 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:03:24 +0200 Subject: [PATCH] Select complete Korean terminal words on long press (#7353) * fix(terminal): select complete Korean words on long press Wide-character continuation cells carry a blank character but occupy no extra glyph. Treat only actual cells as word separators so either half selects the complete word while real spaces still delimit it. * fix(terminal): preserve selected words until a drag begins An unmoved touch release contracted the word selected by the long-press timer. Only extend selection after crossing the existing drag threshold. The Android regression checks the copied whole word after the finger lifts. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../native-terminal-word-selection.android.ad | 19 +++++++ .../terminal-emulator-native-grid.native.tsx | 9 ++- .../terminal-selection.test.ts | 55 +++++++++++++++++++ .../native-renderer/terminal-selection.ts | 15 ++--- 4 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 packages/app/e2e/mobile/agent-device/native-terminal-word-selection.android.ad diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-word-selection.android.ad b/packages/app/e2e/mobile/agent-device/native-terminal-word-selection.android.ad new file mode 100644 index 0000000000..ac029c3cb6 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-terminal-word-selection.android.ad @@ -0,0 +1,19 @@ +# Prerequisite: a native terminal showing the output of echo '한글 문장' +# on its first row, with no active selection. Use a dedicated QA workspace. +# Coordinates target the first half of 문 on a 1080x1920 phone at default +# code size. Override WORD_X/WORD_Y for other device geometry. +context platform=android timeout=30000 retries=0 +env APP_ID=sh.paseo.debug +env WORD_X=105 +env WORD_Y=251 + +open "${APP_ID}" +wait "label=\"Terminal input\"" 10000 +wait "label=\"한글 문장\"" 10000 +longpress "${WORD_X}" "${WORD_Y}" 650 +wait "id=\"terminal-copy\"" 5000 +press "id=\"terminal-copy\"" +# Copy clears the selection; only Android's clipboard preview has this exact +# text, since the unselected terminal row contains the full sentence. +wait "text=\"문장\"" 3000 +close diff --git a/packages/app/src/components/terminal-emulator-native-grid.native.tsx b/packages/app/src/components/terminal-emulator-native-grid.native.tsx index 311528bdac..dcda257116 100644 --- a/packages/app/src/components/terminal-emulator-native-grid.native.tsx +++ b/packages/app/src/components/terminal-emulator-native-grid.native.tsx @@ -868,7 +868,9 @@ function NativeTerminalEmulator({ panState.movedBeyondTapTolerance = true; } if (selectionPress?.status === "selecting") { - updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture)); + if (panState.movedBeyondTapTolerance) { + updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture)); + } return; } @@ -915,7 +917,10 @@ function NativeTerminalEmulator({ onPanResponderRelease: (event, gesture) => { clearSelectionLongPressTimeout(); const selectionPress = selectionPressRef.current; - if (selectionPress?.status === "selecting") { + if ( + selectionPress?.status === "selecting" && + panStateRef.current.movedBeyondTapTolerance + ) { updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture)); } selectionPressRef.current = null; diff --git a/packages/app/src/terminal/native-renderer/terminal-selection.test.ts b/packages/app/src/terminal/native-renderer/terminal-selection.test.ts index ecd2b44ffa..c188671ffe 100644 --- a/packages/app/src/terminal/native-renderer/terminal-selection.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-selection.test.ts @@ -151,6 +151,61 @@ describe("native terminal selection", () => { }); }); + it.each([ + { col: 0, expected: "한글", startCol: 0, endCol: 3 }, + { col: 1, expected: "한글", startCol: 0, endCol: 3 }, + { col: 2, expected: "한글", startCol: 0, endCol: 3 }, + { col: 3, expected: "한글", startCol: 0, endCol: 3 }, + { col: 5, expected: "문장", startCol: 5, endCol: 8 }, + { col: 6, expected: "문장", startCol: 5, endCol: 8 }, + { col: 7, expected: "문장", startCol: 5, endCol: 8 }, + { col: 8, expected: "문장", startCol: 5, endCol: 8 }, + ])( + "selects the whole Korean word from column $col, including continuation cells", + async ({ col, expected, startCol, endCol }) => { + const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 }); + try { + await terminal.write("한글 문장"); + const bounds = terminal.getBufferBounds(); + const row = bounds.cursorRow; + const selection = resolveTerminalWordSelection({ terminal, coordinate: { row, col } }); + + expect({ selection, text: extractTerminalSelectedText({ terminal, selection }) }).toEqual({ + selection: { + start: { row, col: startCol }, + end: { row, col: endCol }, + coordinateEpoch: bounds.coordinateEpoch, + }, + text: expected, + }); + } finally { + terminal.dispose(); + } + }, + ); + + it.each([ + { text: "한글 문장", col: 4, expected: "" }, + { text: "(한글)", col: 2, expected: "한글" }, + { text: "漢字 test", col: 1, expected: "漢字" }, + { text: "a한b x", col: 2, expected: "a한b" }, + { text: "🙂🙂 ok", col: 1, expected: "🙂🙂" }, + { text: "e\u0301한 ok", col: 2, expected: "e\u0301한" }, + { text: "한글", col: 3, expected: "한글" }, + ])("preserves word boundaries in $text at column $col", async ({ text, col, expected }) => { + const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 }); + try { + await terminal.write(text); + const selection = resolveTerminalWordSelection({ + terminal, + coordinate: { row: terminal.getBufferBounds().cursorRow, col }, + }); + expect(extractTerminalSelectedText({ terminal, selection })).toBe(expected); + } finally { + terminal.dispose(); + } + }); + it("maps screen coordinates through the current visible window", () => { expect( hitTestTerminalSelectionCell({ diff --git a/packages/app/src/terminal/native-renderer/terminal-selection.ts b/packages/app/src/terminal/native-renderer/terminal-selection.ts index d222a4cc90..450a3813ac 100644 --- a/packages/app/src/terminal/native-renderer/terminal-selection.ts +++ b/packages/app/src/terminal/native-renderer/terminal-selection.ts @@ -1,4 +1,4 @@ -import type { NativeHeadlessTerminal } from "./headless-terminal-state"; +import type { NativeHeadlessTerminal, NativeTerminalCell } from "./headless-terminal-state"; import type { TerminalGridCellMetrics } from "./terminal-grid-metrics"; export interface TerminalBufferCoordinate { @@ -101,6 +101,10 @@ interface TerminalSelectionState { const TERMINAL_WORD_SEPARATORS = " ()[]{}',\"`"; +function isTerminalWordSeparator(cell: NativeTerminalCell): boolean { + return cell.width !== 0 && TERMINAL_WORD_SEPARATORS.includes(cell.char || " "); +} + function compareCoordinates( left: TerminalBufferCoordinate, right: TerminalBufferCoordinate, @@ -183,8 +187,7 @@ export function resolveTerminalWordSelection( if (!cells || input.coordinate.col < 0 || input.coordinate.col >= cells.length) { return null; } - const target = cells[input.coordinate.col]?.char || " "; - if (TERMINAL_WORD_SEPARATORS.includes(target)) { + if (isTerminalWordSeparator(cells[input.coordinate.col])) { return { start: input.coordinate, end: input.coordinate, @@ -195,13 +198,11 @@ export function resolveTerminalWordSelection( let startCol = input.coordinate.col; let endCol = input.coordinate.col; while (startCol > 0) { - const previous = cells[startCol - 1]?.char || " "; - if (TERMINAL_WORD_SEPARATORS.includes(previous)) break; + if (isTerminalWordSeparator(cells[startCol - 1])) break; startCol -= 1; } while (endCol + 1 < cells.length) { - const next = cells[endCol + 1]?.char || " "; - if (TERMINAL_WORD_SEPARATORS.includes(next)) break; + if (isTerminalWordSeparator(cells[endCol + 1])) break; endCol += 1; }