diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 771416726..9981683b8 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -7,6 +7,10 @@ - Display LaTeX now renders `\underbrace`/`\overbrace` (and the bracket/paren variants) as drawn horizontal braces with centered labels, and stacks `\overset`/`\underset`/`\stackrel` annotations above/below the base instead of falling back to flat inline glyphs. - Display LaTeX renders multi-letter script words (`N_{turns}`) as raised/lowered blocks instead of ragged per-character Unicode sub/superscript glyphs; single letters and digits keep the compact Unicode forms. +### Fixed + +- Fixed macOS IME marked text displacing the prompt editor border when entering fullwidth CJK punctuation ([#5563](https://github.com/can1357/oh-my-pi/issues/5563)). + ## [16.5.2] - 2026-07-14 ### Fixed diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 9c12ec451..2c12c811d 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -866,6 +866,7 @@ export class Editor implements Component, Focusable { let displayWidth = visibleWidth(layoutLine.text); let cursorPaddingOverflow = 0; let decorated = false; + let imeSafeCursorTail = false; const showPromptGutter = promptGutter !== undefined && visibleIndex === 0; const gutterText = promptGutter === undefined ? "" : showPromptGutter ? promptGutter.firstLine : promptGutter.continuation; @@ -931,6 +932,10 @@ export class Editor implements Component, Focusable { displayText = this.#renderTerminalCursorMarker(before, marker, lineContentWidth); } else { displayText = before + marker + after; + // Terminal frontends render IME marked text locally before committed bytes + // reach the application. Keep the end-of-input cursor row empty to its + // right so that insertion cannot shift box chrome onto the next row. + imeSafeCursorTail = after.length === 0 && borderVisible; } } } else if (hasCursor && !this.#useTerminalCursor) { @@ -1022,6 +1027,15 @@ export class Editor implements Component, Focusable { // trailing `─`, but never the corner/vertical bar itself. const isLastLine = visibleIndex === visibleLayoutLines.length - 1; const rightChromeCells = Math.max(1, paddingX + 1 - cursorPaddingOverflow); + if (isLastLine && imeSafeCursorTail) { + const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`); + const bottomBorder = this.borderColor( + `${box.bottomLeft}${box.horizontal.repeat(Math.max(0, width - 2))}${box.bottomRight}`, + ); + result.push(leftBorder + displayText); + result.push(bottomBorder); + continue; + } if (isLastLine) { const rightPad = Math.max(0, rightChromeCells - 2); const includeHorizontal = rightChromeCells >= 2; diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 65503cbd8..294a6710c 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -3,13 +3,14 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { stripVTControlCharacters } from "node:util"; -import { CURSOR_MARKER } from "@oh-my-pi/pi-tui"; +import { CURSOR_MARKER, TUI } from "@oh-my-pi/pi-tui"; import { CombinedAutocompleteProvider } from "@oh-my-pi/pi-tui/autocomplete"; import { Editor } from "@oh-my-pi/pi-tui/components/editor"; import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "@oh-my-pi/pi-tui/keybindings"; import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys"; import { visibleWidth } from "@oh-my-pi/pi-tui/utils"; import { defaultEditorTheme } from "./test-themes"; +import { VirtualTerminal } from "./virtual-terminal"; describe("Editor component", () => { afterEach(() => { @@ -852,6 +853,36 @@ describe("Editor component", () => { } }); + it("keeps terminal-local IME preedit from displacing the editor border (#5563)", async () => { + const width = 20; + const terminal = new VirtualTerminal(width, 6, 1_000); + const tui = new TUI(terminal, true); + const editor = new Editor(defaultEditorTheme); + tui.addChild(editor); + tui.setFocus(editor); + + try { + tui.start(); + await terminal.waitForRender(); + for (const char of "ast") editor.handleInput(char); + tui.requestRender(); + await terminal.waitForRender(); + + const beforePreedit = terminal.getViewport().map(row => row.trimEnd()); + expect(beforePreedit.slice(0, 3)).toEqual(["+------------------+", "| ast", "+------------------+"]); + + // macOS Terminal renders marked text locally in insertion mode before + // committed bytes reach OMP. The open cursor row must not carry right + // chrome that the marked text can shift onto another row. + terminal.write("\x1b[4hast,\x1b[4l"); + const afterPreedit = terminal.getViewport().map(row => row.trimEnd()); + expect(afterPreedit[1]).toBe("| astast,"); + expect(afterPreedit[2]).toBe(beforePreedit[2]); + } finally { + tui.stop(); + } + }); + it("shows cursor at end before wrap and wraps on next char", () => { for (const paddingX of [0, 1]) { const editor = new Editor({ ...defaultEditorTheme, editorPaddingX: paddingX });