diff --git a/server/src/bridge/frame-to-ansi.test.ts b/server/src/bridge/frame-to-ansi.test.ts index 3306c55a..267de342 100644 --- a/server/src/bridge/frame-to-ansi.test.ts +++ b/server/src/bridge/frame-to-ansi.test.ts @@ -93,19 +93,27 @@ describe("frameToAnsi", () => { expect(out).toContain("L\x1b]8;;\x1b\\"); }); - test("positions and shapes the cursor", () => { - const f: FrameData = { - ...frame( - Array.from({ length: 12 }, () => cell("a")), - 3, - 4, - ), - cursor: { x: 2, y: 3, visible: true, shape: 5 }, - }; - const out = frameToAnsi(f); - expect(out).toContain("\x1b[4;3H"); - expect(out).toContain("\x1b[5 q\x1b[?25h"); - }); + test.each([true, false])( + "positions the cursor when visible=%s", + (visible) => { + const f: FrameData = { + ...frame( + Array.from({ length: 12 }, () => cell("a")), + 3, + 4, + ), + cursor: { x: 2, y: 3, visible, shape: 5 }, + }; + const out = frameToAnsi(f); + expect(out).toContain("\x1b[4;3H"); + if (visible) { + expect(out).toEndWith("\x1b[5 q\x1b[?25h"); + } else { + expect(out).toEndWith("\x1b[?25l"); + expect(out).not.toContain("\x1b[?25h"); + } + }, + ); test("positions rows without wrapping the cell grid", () => { const out = frameToAnsi( diff --git a/server/src/bridge/frame-to-ansi.ts b/server/src/bridge/frame-to-ansi.ts index 25ed6396..3106368e 100644 --- a/server/src/bridge/frame-to-ansi.ts +++ b/server/src/bridge/frame-to-ansi.ts @@ -122,9 +122,10 @@ export function frameToAnsi( } out += "\x1b[?7h"; const cursor = frame.cursor; - if (cursor?.visible && cursor.x < width && cursor.y < height) { + if (cursor && cursor.x < width && cursor.y < height) { + // Hidden cursors still anchor xterm's IME composition and candidate window. out += `${RESET}\x1b[${cursor.y + 1};${cursor.x + 1}H`; - out += `\x1b[${cursor.shape} q\x1b[?25h`; + out += cursor.visible ? `\x1b[${cursor.shape} q\x1b[?25h` : "\x1b[?25l"; } else { out += "\x1b[?25l"; } diff --git a/web/src/endpointFrame.test.ts b/web/src/endpointFrame.test.ts index 7eb010d7..391c04cd 100644 --- a/web/src/endpointFrame.test.ts +++ b/web/src/endpointFrame.test.ts @@ -48,6 +48,41 @@ test.each(["δΈ­", "πŸ™‚", "πŸ‘©β€πŸ’»", "πŸ‡¨πŸ‡³", "「゙", "e\u0301"])( }, ); +test("keeps the IME anchor at hidden cursor coordinates across repaints", async () => { + const term = new Terminal({ cols: 10, rows: 5 }); + const frame: FrameData = { + width: 10, + height: 5, + cursor: null, + hyperlinks: [], + cells: Array.from({ length: 50 }, () => ({ + symbol: " ", + fg: 0, + bg: 0, + modifier: 0, + skip: false, + hyperlink: null, + })), + }; + try { + for (const [x, y, visible] of [ + [2, 1, false], + [4, 0, false], + [4, 0, true], + [0, 2, false], + ] as const) { + frame.cursor = { x, y, visible, shape: 1 }; + const ansi = frameToAnsi(frame); + await new Promise((resolve) => term.write(ansi, resolve)); + expect(term.buffer.active.cursorX).toBe(x); + expect(term.buffer.active.cursorY).toBe(y); + expect(ansi).toEndWith(visible ? "\x1b[?25h" : "\x1b[?25l"); + } + } finally { + term.dispose(); + } +}); + test.each([10, 11, 14])( "a %i-column TUI frame never wraps or scrolls a 10-column viewport", async (width) => {