From a2a1624c8656a7f633e455489e4ee22c73b278a5 Mon Sep 17 00:00:00 2001 From: chenxin Date: Wed, 14 Oct 2026 16:23:22 +0800 Subject: [PATCH] fix: backspace once per character when replacing wide-char tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit calculateReplacement used tokenLength, a display width, as the number of backspaces to send. Shells delete one character per backspace, so a token containing wide characters (CJK) over-deleted: typing `cd 中` and accepting `中文目录/` sent two backspaces for one character and also removed the space before it, producing `cd中文目录/`. Convert the display width to a character count before sending backspaces. Quotes and escape characters are single-width, so the difference between the token text's display width and its character count is exactly the extra width to subtract. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/runtime/replacement.ts | 12 +++++++++--- src/tests/runtime/replacement.test.ts | 11 +++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/runtime/replacement.ts b/src/runtime/replacement.ts index 81a92eb0..d22e7288 100644 --- a/src/runtime/replacement.ts +++ b/src/runtime/replacement.ts @@ -3,6 +3,7 @@ import { CommandToken } from "./parser.js"; import { Suggestion } from "./model.js"; +import { wcswidth } from "../utils/unicode.js"; export type ReplacementAction = { backspaceCount: number; @@ -15,6 +16,11 @@ const commonPrefixLength = (a: string, b: string): number => { return i; }; +// Shells delete one character per backspace, but tokenLength is a display width in which wide +// characters (e.g. CJK) count as 2. Quotes and escapes are single-width, so the difference between +// the width and the character count of the token text is exactly the extra width to remove. +const typedCharCount = (token: CommandToken): number => token.tokenLength - (wcswidth(token.token) - Array.from(token.token).length); + export const calculateReplacement = (token: CommandToken | undefined, suggestion: Suggestion): ReplacementAction | undefined => { const suggestionText = suggestion.insertValue ?? suggestion.name + " "; @@ -24,10 +30,10 @@ export const calculateReplacement = (token: CommandToken | undefined, suggestion } const typedText = token.token; - const tokenDisplayWidth = token.tokenLength; + const tokenCharCount = typedCharCount(token); if (suggestion.insertValue != null && suggestion.insertValue !== suggestion.name) { - return { backspaceCount: tokenDisplayWidth, insertText: suggestionText }; + return { backspaceCount: tokenCharCount, insertText: suggestionText }; } const target = suggestion.name + " "; @@ -39,7 +45,7 @@ export const calculateReplacement = (token: CommandToken | undefined, suggestion return { backspaceCount: 0, insertText: remaining }; } - return { backspaceCount: tokenDisplayWidth, insertText: target }; + return { backspaceCount: tokenCharCount, insertText: target }; }; export const applyReplacement = (action: ReplacementAction): string => { diff --git a/src/tests/runtime/replacement.test.ts b/src/tests/runtime/replacement.test.ts index 43b330f5..53c996ec 100644 --- a/src/tests/runtime/replacement.test.ts +++ b/src/tests/runtime/replacement.test.ts @@ -64,12 +64,19 @@ describe("calculateReplacement", () => { insertText: "好 ", }); }); - test("wide-char divergent uses display width for backspace", () => { + test("wide-char divergent uses character count for backspace", () => { + // shells delete one character per backspace regardless of its display width expect(calculateReplacement(makeToken({ token: "你", tokenLength: 2 }), makeSuggestion({ name: "世界" }))).toEqual({ - backspaceCount: 2, + backspaceCount: 1, insertText: "世界 ", }); }); + test("mixed-width divergent deletes one backspace per character", () => { + expect(calculateReplacement(makeToken({ token: "a你", tokenLength: 3 }), makeSuggestion({ name: "b" }))).toEqual({ + backspaceCount: 2, + insertText: "b ", + }); + }); test("option prefix appends remaining", () => { expect(calculateReplacement(makeToken({ token: "--ver", isOption: true }), makeSuggestion({ name: "--version" }))).toEqual({ backspaceCount: 0,