From 4de495dd9543ffe2cddff5af2e76b4832949cda2 Mon Sep 17 00:00:00 2001 From: yc <1322618111@qq.com> Date: Sat, 13 Jun 2026 20:10:14 +0800 Subject: [PATCH] fix(tui): correct IME cursor placement --- .changeset/fix-ime-cursor-placement.md | 5 +++ .../tui/components/editor/custom-editor.ts | 25 +++++++++++++- apps/kimi-code/src/tui/tui-state.ts | 2 +- .../components/editor/custom-editor.test.ts | 33 +++++++++++++++++-- .../test/tui/create-tui-state.test.ts | 1 + 5 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-ime-cursor-placement.md diff --git a/.changeset/fix-ime-cursor-placement.md b/.changeset/fix-ime-cursor-placement.md new file mode 100644 index 000000000..3fbed6b0a --- /dev/null +++ b/.changeset/fix-ime-cursor-placement.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix IME candidate placement and cursor rendering in the terminal editor. diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index ded2af648..d3dae07ac 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -4,6 +4,7 @@ import { Editor, + CURSOR_MARKER, isKeyRelease, matchesKey, Key, @@ -23,6 +24,8 @@ const ANSI_SGR = /\u001B\[[0-9;]*m/g; const PASTE_MARKER_RE = /\[paste #(\d+)(?: (?:\+\d+ lines|\d+ chars))?\]/g; const BRACKET_PASTE_START = '\u001B[200~'; const BRACKET_PASTE_END = '\u001B[201~'; +const FAKE_CURSOR_START = '\u001B[7m'; +const FAKE_CURSOR_END = '\u001B[0m'; // Kitty keyboard protocol CSI-u sequence: ESC [ keycode ; modifier[:eventType] u. // We intentionally match only the simple two-field form — enough to rewrite @@ -239,9 +242,10 @@ export class CustomEditor extends Editor { // overwrite it (e.g. plan-mode / slash-context highlight via // `editor.borderColor = chalk.hex(primary)`), so we route corners and // side bars through the same hook to stay in sync. - return wrapWithSideBorders(lines, (s) => this.borderColor(s), { + const wrapped = wrapWithSideBorders(lines, (s) => this.borderColor(s), { connectedAbove: this.connectedAbove && !this.borderHighlighted, }); + return wrapped.map(removeFakeCursorAtHardwareMarker); } override handleInput(data: string): void { @@ -362,6 +366,25 @@ export class CustomEditor extends Editor { } } +export function removeFakeCursorAtHardwareMarker(line: string): string { + const markerStart = line.indexOf(CURSOR_MARKER); + if (markerStart < 0) return line; + + const fakeCursorStart = markerStart + CURSOR_MARKER.length; + if (!line.startsWith(FAKE_CURSOR_START, fakeCursorStart)) return line; + + const fakeCursorEnd = line.indexOf( + FAKE_CURSOR_END, + fakeCursorStart + FAKE_CURSOR_START.length, + ); + if (fakeCursorEnd < 0) return line; + + const cursorTextStart = fakeCursorStart + FAKE_CURSOR_START.length; + const cursorText = line.slice(cursorTextStart, fakeCursorEnd); + const afterFakeCursor = fakeCursorEnd + FAKE_CURSOR_END.length; + return line.slice(0, fakeCursorStart) + cursorText + line.slice(afterFakeCursor); +} + /** * Return a copy of `line` with the first `/token` coloured using `hex`. * For `/goal next manage`, also colour the command-path tokens. diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 8ff43694b..ac6c2f7bc 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -58,7 +58,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState { const theme = currentTheme; const terminal = new ProcessTerminal(); - const ui = new TUI(terminal); + const ui = new TUI(terminal, true); const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index fa30293cc..20795e21e 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -4,9 +4,13 @@ import type { AutocompleteSuggestions, TUI, } from '@earendil-works/pi-tui'; +import { CURSOR_MARKER } from '@earendil-works/pi-tui'; import { describe, expect, it, vi } from 'vitest'; -import { CustomEditor } from '#/tui/components/editor/custom-editor'; +import { + CustomEditor, + removeFakeCursorAtHardwareMarker, +} from '#/tui/components/editor/custom-editor'; function makeEditor(): CustomEditor { const tui = { @@ -132,6 +136,31 @@ describe('CustomEditor Kitty key release handling', () => { }); }); +describe('removeFakeCursorAtHardwareMarker', () => { + it('removes the rendered fake cursor and keeps the hardware cursor marker in place', () => { + const line = `before${CURSOR_MARKER}\u001B[7m \u001B[0mafter`; + + expect(removeFakeCursorAtHardwareMarker(line)).toBe(`before${CURSOR_MARKER} after`); + }); + + it('renders a focused editor with hardware cursor positioning but no fake cursor block', () => { + const editor = makeEditor(); + editor.focused = true; + editor.setText('test'); + + const output = editor.render(40).join('\n'); + + expect(output).toContain(CURSOR_MARKER); + expect(output).not.toContain('\u001B[7m'); + }); + + it('leaves unrelated marker-like lines untouched', () => { + const line = `before${CURSOR_MARKER}after`; + + expect(removeFakeCursorAtHardwareMarker(line)).toBe(line); + }); +}); + describe('CustomEditor paste marker expansion', () => { const PASTE_START = '\x1b[200~'; const PASTE_END = '\x1b[201~'; @@ -199,7 +228,7 @@ describe('CustomEditor paste marker expansion', () => { expect(editor.getText()).toMatch(/\[paste #1/); - editor.handleInput('\x16'); + editor.handleInput(process.platform === 'win32' ? '\x1b[118;3u' : '\x16'); expect(editor.getText()).not.toContain('[paste #'); expect(editor.getText()).toContain(longText); diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index eb9dbd986..65a2006fa 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -47,6 +47,7 @@ describe('createTUIState', () => { // UI objects are created. expect(state.ui).toBeDefined(); + expect(state.ui.getShowHardwareCursor()).toBe(true); expect(state.terminal).toBeDefined(); expect(state.transcriptContainer).toBeDefined(); expect(state.activityContainer).toBeDefined();