From 6dbb9fa05dce1534a6b2b0877500093937acb2dd Mon Sep 17 00:00:00 2001 From: GreyElaina Date: Wed, 7 Jan 2026 11:41:42 +0800 Subject: [PATCH 1/6] feat(core): add grapheme segmentation for proper emoji/CJK handling in StdinBuffer - Add grapheme-segmenter.ts using Intl.Segmenter for Unicode-correct text segmentation - Update StdinBuffer to emit complete grapheme clusters instead of single characters - Preserve Kitty keyboard protocol sequences unchanged for downstream parsing - Add comprehensive tests for grapheme cluster handling --- packages/core/src/lib/grapheme-segmenter.ts | 85 ++++++++++ packages/core/src/lib/stdin-buffer.test.ts | 97 ++++++++++- packages/core/src/lib/stdin-buffer.ts | 179 ++++++++------------ 3 files changed, 250 insertions(+), 111 deletions(-) create mode 100644 packages/core/src/lib/grapheme-segmenter.ts diff --git a/packages/core/src/lib/grapheme-segmenter.ts b/packages/core/src/lib/grapheme-segmenter.ts new file mode 100644 index 000000000..2b23567f8 --- /dev/null +++ b/packages/core/src/lib/grapheme-segmenter.ts @@ -0,0 +1,85 @@ +let segmenter: Intl.Segmenter | null = null +let initPromise: Promise | null = null +let initError: Error | null = null + +function initializePolyfill(): Promise { + if (initPromise) return initPromise + + initPromise = (async () => { + if (typeof Intl === "undefined" || typeof (Intl as any).Segmenter !== "function") { + try { + await import("@formatjs/intl-segmenter/polyfill-force.js") + } catch (e) { + initError = new Error( + "Failed to load Intl.Segmenter polyfill. Please ensure @formatjs/intl-segmenter is installed or use a runtime that supports Intl.Segmenter natively.", + ) + } + } + })() + + return initPromise +} + +initializePolyfill() + +export function getGraphemeSegmenter(): Intl.Segmenter { + if (segmenter) return segmenter + + if (typeof Intl !== "undefined" && typeof (Intl as any).Segmenter === "function") { + segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }) + return segmenter + } + + if (initError) { + throw initError + } + + throw new Error( + "Intl.Segmenter is not available. Please ensure your runtime supports it or install @formatjs/intl-segmenter", + ) +} + +function isHighSurrogate(code: number): boolean { + return code >= 0xd800 && code <= 0xdbff +} + +function isLowSurrogate(code: number): boolean { + return code >= 0xdc00 && code <= 0xdfff +} + +export function isSingleGrapheme(s: string): boolean { + if (s.length === 0) return false + if (s.length === 1) return true + + const first = s.charCodeAt(0) + if (first < 128) { + const second = s.charCodeAt(1) + if (second < 128) return false + } + + const iter = getGraphemeSegmenter().segment(s)[Symbol.iterator]() + iter.next() + return iter.next().done === true +} + +export function firstGrapheme(str: string): string { + if (str.length === 0) return "" + + const firstCode = str.charCodeAt(0) + if (firstCode < 128) { + if (str.length === 1) return str[0]! + const secondCode = str.charCodeAt(1) + if (secondCode < 128) return str[0]! + } else if (str.length === 1) { + return str[0]! + } else if (isHighSurrogate(firstCode)) { + const secondCode = str.charCodeAt(1) + if (isLowSurrogate(secondCode) && str.length === 2) { + return str.substring(0, 2) + } + } + + const segments = getGraphemeSegmenter().segment(str) + const first = segments[Symbol.iterator]().next() + return first.done ? "" : first.value.segment +} diff --git a/packages/core/src/lib/stdin-buffer.test.ts b/packages/core/src/lib/stdin-buffer.test.ts index f05ae357a..66a9bba21 100644 --- a/packages/core/src/lib/stdin-buffer.test.ts +++ b/packages/core/src/lib/stdin-buffer.test.ts @@ -15,12 +15,16 @@ describe("StdinBuffer", () => { }) }) - // Helper to process data through the buffer function processInput(data: string | Buffer): void { buffer.process(data) } - // Helper to wait for async operations + function flushBuffer(): void { + for (const seq of buffer.flush()) { + emittedSequences.push(seq) + } + } + async function wait(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } @@ -666,6 +670,95 @@ describe("StdinBuffer", () => { }) }) + describe("Grapheme Cluster Handling", () => { + it("should keep basic emoji as single sequence", () => { + processInput("๐ŸŒŸ") + expect(emittedSequences).toEqual(["๐ŸŒŸ"]) + }) + + it("should keep multiple basic emoji as separate sequences", () => { + processInput("๐ŸŒŸ๐Ÿ‘๐ŸŽ‰") + expect(emittedSequences).toEqual(["๐ŸŒŸ", "๐Ÿ‘", "๐ŸŽ‰"]) + }) + + it("should keep ZWJ family emoji as single sequence", () => { + processInput("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ") + expect(emittedSequences).toEqual(["๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"]) + }) + + it("should keep flag emoji as single sequence", () => { + processInput("๐Ÿ‡บ๐Ÿ‡ธ") + expect(emittedSequences).toEqual(["๐Ÿ‡บ๐Ÿ‡ธ"]) + }) + + it("should keep skin tone emoji as single sequence", () => { + processInput("๐Ÿ‘‹๐Ÿป") + expect(emittedSequences).toEqual(["๐Ÿ‘‹๐Ÿป"]) + }) + + it("should keep emoji with VS16 as single sequence", () => { + processInput("โค๏ธ") + expect(emittedSequences).toEqual(["โค๏ธ"]) + }) + + it("should handle mixed ASCII and emoji", () => { + processInput("Hi๐Ÿ‘‹there") + expect(emittedSequences).toEqual(["H", "i", "๐Ÿ‘‹", "t", "h", "e", "r", "e"]) + }) + + it("should handle mixed CJK and emoji", () => { + processInput("ไธ–็•Œ๐ŸŒ") + expect(emittedSequences).toEqual(["ไธ–", "็•Œ", "๐ŸŒ"]) + }) + + it("should handle complex ZWJ sequences", () => { + processInput("๐Ÿ‘ฉโ€๐Ÿš€") + expect(emittedSequences).toEqual(["๐Ÿ‘ฉโ€๐Ÿš€"]) + }) + + it("should handle rainbow flag", () => { + processInput("๐Ÿณ๏ธโ€๐ŸŒˆ") + expect(emittedSequences).toEqual(["๐Ÿณ๏ธโ€๐ŸŒˆ"]) + }) + + it("should handle emoji between escape sequences", () => { + processInput("\x1b[A๐ŸŒŸ\x1b[B") + expect(emittedSequences).toEqual(["\x1b[A", "๐ŸŒŸ", "\x1b[B"]) + }) + + it("should handle ASCII with variation selector (keycap)", () => { + processInput("#๏ธโƒฃ") + expect(emittedSequences).toEqual(["#๏ธโƒฃ"]) + }) + }) + + describe("Kitty Keyboard Protocol Sequences", () => { + const kitty = (codepoint: number) => `\x1b[${codepoint}u` + + it("should preserve Kitty sequences as-is for downstream parsing", () => { + processInput(kitty(97)) + expect(emittedSequences).toEqual([kitty(97)]) + }) + + it("should preserve Kitty emoji sequences as-is", () => { + processInput(kitty(0x1f600)) + expect(emittedSequences).toEqual([kitty(0x1f600)]) + }) + + it("should handle mixed Kitty and regular escape sequences", () => { + processInput(kitty(0x1f600)) + processInput("\x1b[A") + expect(emittedSequences).toEqual([kitty(0x1f600), "\x1b[A"]) + }) + + it("should handle multiple Kitty sequences", () => { + processInput(kitty(0x1f468)) + processInput(kitty(0x200d)) + processInput(kitty(0x1f469)) + expect(emittedSequences).toEqual([kitty(0x1f468), kitty(0x200d), kitty(0x1f469)]) + }) + }) + describe("Destroy", () => { it("should clear buffer on destroy", () => { processInput("\x1b[<35") diff --git a/packages/core/src/lib/stdin-buffer.ts b/packages/core/src/lib/stdin-buffer.ts index 8926c76ab..94ea253c8 100644 --- a/packages/core/src/lib/stdin-buffer.ts +++ b/packages/core/src/lib/stdin-buffer.ts @@ -15,6 +15,7 @@ */ import { EventEmitter } from "events" +import { firstGrapheme } from "./grapheme-segmenter" const ESC = "\x1b" const BRACKETED_PASTE_START = "\x1b[200~" @@ -122,58 +123,15 @@ function isCompleteCsiSequence(data: string): "complete" | "incomplete" { return "incomplete" } -/** - * Check if OSC sequence is complete - * OSC sequences: ESC ] ... ST (where ST is ESC \ or BEL) - */ -function isCompleteOscSequence(data: string): "complete" | "incomplete" { - if (!data.startsWith(ESC + "]")) { - return "complete" - } - - // OSC sequences end with ST (ESC \) or BEL (\x07) - if (data.endsWith(ESC + "\\") || data.endsWith("\x07")) { - return "complete" - } - +function isCompleteStTerminatedSequence(data: string, prefix: string, allowBel = false): "complete" | "incomplete" { + if (!data.startsWith(ESC + prefix)) return "complete" + if (data.endsWith(ESC + "\\") || (allowBel && data.endsWith("\x07"))) return "complete" return "incomplete" } -/** - * Check if DCS (Device Control String) sequence is complete - * DCS sequences: ESC P ... ST (where ST is ESC \) - * Used for XTVersion responses like ESC P >| ... ESC \ - */ -function isCompleteDcsSequence(data: string): "complete" | "incomplete" { - if (!data.startsWith(ESC + "P")) { - return "complete" - } - - // DCS sequences end with ST (ESC \) - if (data.endsWith(ESC + "\\")) { - return "complete" - } - - return "incomplete" -} - -/** - * Check if APC (Application Program Command) sequence is complete - * APC sequences: ESC _ ... ST (where ST is ESC \) - * Used for Kitty graphics responses like ESC _ G ... ESC \ - */ -function isCompleteApcSequence(data: string): "complete" | "incomplete" { - if (!data.startsWith(ESC + "_")) { - return "complete" - } - - // APC sequences end with ST (ESC \) - if (data.endsWith(ESC + "\\")) { - return "complete" - } - - return "incomplete" -} +const isCompleteOscSequence = (data: string) => isCompleteStTerminatedSequence(data, "]", true) +const isCompleteDcsSequence = (data: string) => isCompleteStTerminatedSequence(data, "P") +const isCompleteApcSequence = (data: string) => isCompleteStTerminatedSequence(data, "_") /** * Split accumulated buffer into complete sequences @@ -211,9 +169,11 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain return { sequences, remainder: remaining } } } else { - // Not an escape sequence - take a single character - sequences.push(remaining[0]) - pos++ + // Not an escape sequence - take a single grapheme cluster + // This correctly handles emoji, CJK characters, and other multi-byte sequences + const grapheme = firstGrapheme(remaining) + sequences.push(grapheme) + pos += grapheme.length } } @@ -249,6 +209,20 @@ export class StdinBuffer extends EventEmitter { this.timeoutMs = options.timeout ?? 10 } + private tryCompletePaste(): { completed: boolean; remaining: string } { + const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END) + if (endIndex === -1) return { completed: false, remaining: "" } + + const pastedContent = this.pasteBuffer.slice(0, endIndex) + const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length) + + this.pasteMode = false + this.pasteBuffer = "" + this.emit("paste", pastedContent) + + return { completed: true, remaining } + } + public process(data: string | Buffer): void { // Clear any pending timeout if (this.timeout) { @@ -281,71 +255,57 @@ export class StdinBuffer extends EventEmitter { if (this.pasteMode) { this.pasteBuffer += this.buffer this.buffer = "" - - const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END) - if (endIndex !== -1) { - const pastedContent = this.pasteBuffer.slice(0, endIndex) - const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length) - - this.pasteMode = false - this.pasteBuffer = "" - - this.emit("paste", pastedContent) - - if (remaining.length > 0) { - this.process(remaining) - } + const result = this.tryCompletePaste() + if (result.remaining.length > 0) { + this.buffer = result.remaining + return this.processNonPasteBuffer() } return } - const startIndex = this.buffer.indexOf(BRACKETED_PASTE_START) - if (startIndex !== -1) { - if (startIndex > 0) { - const beforePaste = this.buffer.slice(0, startIndex) - const result = extractCompleteSequences(beforePaste) - for (const sequence of result.sequences) { - this.emit("data", sequence) - } - } - - this.buffer = this.buffer.slice(startIndex + BRACKETED_PASTE_START.length) - this.pasteMode = true - this.pasteBuffer = this.buffer - this.buffer = "" - - const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END) - if (endIndex !== -1) { - const pastedContent = this.pasteBuffer.slice(0, endIndex) - const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length) - - this.pasteMode = false - this.pasteBuffer = "" + this.processNonPasteBuffer() + } - this.emit("paste", pastedContent) + private processNonPasteBuffer(): void { + while (true) { + const startIndex = this.buffer.indexOf(BRACKETED_PASTE_START) + if (startIndex !== -1) { + if (startIndex > 0) { + const result = extractCompleteSequences(this.buffer.slice(0, startIndex)) + for (const seq of result.sequences) { + this.emit("data", seq) + } + } - if (remaining.length > 0) { - this.process(remaining) + this.buffer = this.buffer.slice(startIndex + BRACKETED_PASTE_START.length) + this.pasteMode = true + this.pasteBuffer = this.buffer + this.buffer = "" + const result = this.tryCompletePaste() + if (result.remaining.length > 0) { + this.buffer = result.remaining + continue } + return } - return - } - const result = extractCompleteSequences(this.buffer) - this.buffer = result.remainder + const result = extractCompleteSequences(this.buffer) + this.buffer = result.remainder - for (const sequence of result.sequences) { - this.emit("data", sequence) - } + for (const seq of result.sequences) { + this.emit("data", seq) + } - if (this.buffer.length > 0) { - this.timeout = setTimeout(() => { - const flushed = this.flush() + if (this.buffer.length > 0) { + this.timeout = setTimeout(() => { + const flushed = this.flush() - for (const sequence of flushed) { - this.emit("data", sequence) - } - }, this.timeoutMs) + for (const sequence of flushed) { + this.emit("data", sequence) + } + }, this.timeoutMs) + } + return } } @@ -355,12 +315,13 @@ export class StdinBuffer extends EventEmitter { this.timeout = null } - if (this.buffer.length === 0) { - return [] + const sequences: string[] = [] + + if (this.buffer.length > 0) { + sequences.push(this.buffer) + this.buffer = "" } - const sequences = [this.buffer] - this.buffer = "" return sequences } From 160866955aa50da15cfdef3b6c429ec229b69146 Mon Sep 17 00:00:00 2001 From: GreyElaina Date: Wed, 7 Jan 2026 12:03:13 +0800 Subject: [PATCH 2/6] feat(core): add Kitty emoji reassembly in KeyHandler Buffer Kitty keyboard protocol emoji sequences and reassemble them into complete grapheme clusters before emitting keypress events. - Add emoji codepoint detection helpers (isGraphemeExtender, canStartGraphemeCluster) - Buffer Kitty sequences that form multi-codepoint emoji (ZWJ, flags, skin tones, keycaps) - Flush buffer on timeout or when non-emoji input arrives - Preserve all raw sequences in the emitted KeyEvent.raw field - Add comprehensive tests for emoji reassembly scenarios --- packages/core/src/lib/KeyHandler.test.ts | 157 ++++++++++++++++++++ packages/core/src/lib/KeyHandler.ts | 173 +++++++++++++++++++++-- 2 files changed, 319 insertions(+), 11 deletions(-) diff --git a/packages/core/src/lib/KeyHandler.test.ts b/packages/core/src/lib/KeyHandler.test.ts index 547b94174..eff238fdd 100644 --- a/packages/core/src/lib/KeyHandler.test.ts +++ b/packages/core/src/lib/KeyHandler.test.ts @@ -651,3 +651,160 @@ test("KeyHandler - error in one event type does not prevent other event types fr expect(keypressCalled).toBe(true) expect(pasteCalled).toBe(true) }) + +test("KeyHandler - Kitty emoji reassembly: basic emoji", async () => { + const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 }) + const events: KeyEvent[] = [] + handler.on("keypress", (key: KeyEvent) => events.push(key)) + + handler.processInput("\x1b[128512u") // ๐Ÿ˜€ U+1F600 + await new Promise((r) => setTimeout(r, 10)) + + expect(events).toHaveLength(1) + expect(events[0].name).toBe("๐Ÿ˜€") + expect(events[0].sequence).toBe("๐Ÿ˜€") + expect(events[0].raw).toBe("\x1b[128512u") + handler.destroy() +}) + +test("KeyHandler - Kitty emoji reassembly: ZWJ family", async () => { + const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 }) + const events: KeyEvent[] = [] + handler.on("keypress", (key: KeyEvent) => events.push(key)) + + handler.processInput("\x1b[128104u") // ๐Ÿ‘จ U+1F468 + handler.processInput("\x1b[8205u") // ZWJ U+200D + handler.processInput("\x1b[128105u") // ๐Ÿ‘ฉ U+1F469 + handler.processInput("\x1b[8205u") // ZWJ + handler.processInput("\x1b[128103u") // ๐Ÿ‘ง U+1F467 + await new Promise((r) => setTimeout(r, 10)) + + expect(events).toHaveLength(1) + expect(events[0].name).toBe("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง") + expect(events[0].sequence).toBe("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง") + expect(events[0].raw).toBe("\x1b[128104u\x1b[8205u\x1b[128105u\x1b[8205u\x1b[128103u") + handler.destroy() +}) + +test("KeyHandler - Kitty emoji reassembly: flag emoji", async () => { + const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 }) + const events: KeyEvent[] = [] + handler.on("keypress", (key: KeyEvent) => events.push(key)) + + handler.processInput("\x1b[127482u") // ๐Ÿ‡บ U+1F1FA + handler.processInput("\x1b[127480u") // ๐Ÿ‡ธ U+1F1F8 + await new Promise((r) => setTimeout(r, 10)) + + expect(events).toHaveLength(1) + expect(events[0].name).toBe("๐Ÿ‡บ๐Ÿ‡ธ") + expect(events[0].sequence).toBe("๐Ÿ‡บ๐Ÿ‡ธ") + handler.destroy() +}) + +test("KeyHandler - Kitty emoji reassembly: skin tone modifier", async () => { + const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 }) + const events: KeyEvent[] = [] + handler.on("keypress", (key: KeyEvent) => events.push(key)) + + handler.processInput("\x1b[128075u") // ๐Ÿ‘‹ U+1F44B + handler.processInput("\x1b[127995u") // ๐Ÿป U+1F3FB (light skin tone) + await new Promise((r) => setTimeout(r, 10)) + + expect(events).toHaveLength(1) + expect(events[0].name).toBe("๐Ÿ‘‹๐Ÿป") + expect(events[0].sequence).toBe("๐Ÿ‘‹๐Ÿป") + handler.destroy() +}) + +test("KeyHandler - Kitty emoji reassembly: keycap", async () => { + const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 }) + const events: KeyEvent[] = [] + handler.on("keypress", (key: KeyEvent) => events.push(key)) + + handler.processInput("\x1b[35u") // # U+0023 + handler.processInput("\x1b[65039u") // VS16 U+FE0F + handler.processInput("\x1b[8419u") // Keycap U+20E3 + await new Promise((r) => setTimeout(r, 10)) + + expect(events).toHaveLength(1) + expect(events[0].name).toBe("#๏ธโƒฃ") + expect(events[0].sequence).toBe("#๏ธโƒฃ") + handler.destroy() +}) + +test("KeyHandler - Kitty emoji reassembly: flush on non-emoji input", async () => { + const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 }) + const events: KeyEvent[] = [] + handler.on("keypress", (key: KeyEvent) => events.push(key)) + + handler.processInput("\x1b[128075u") // ๐Ÿ‘‹ + handler.processInput("\x1b[97u") // 'a' - not an extender, should flush + + expect(events).toHaveLength(2) + expect(events[0].name).toBe("๐Ÿ‘‹") + expect(events[1].name).toBe("a") + handler.destroy() +}) + +test("KeyHandler - Kitty emoji reassembly: non-emoji Kitty sequences pass through", () => { + const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 }) + const events: KeyEvent[] = [] + handler.on("keypress", (key: KeyEvent) => events.push(key)) + + handler.processInput("\x1b[97u") // 'a' + handler.processInput("\x1b[98u") // 'b' + + expect(events).toHaveLength(2) + expect(events[0].name).toBe("a") + expect(events[1].name).toBe("b") + handler.destroy() +}) + +test("KeyHandler - Kitty emoji reassembly: multiple flags split correctly", async () => { + const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 }) + const events: KeyEvent[] = [] + handler.on("keypress", (key: KeyEvent) => events.push(key)) + + handler.processInput("\x1b[127482u") // ๐Ÿ‡บ U+1F1FA + handler.processInput("\x1b[127480u") // ๐Ÿ‡ธ U+1F1F8 + handler.processInput("\x1b[127471u") // ๐Ÿ‡ฏ U+1F1EF + handler.processInput("\x1b[127477u") // ๐Ÿ‡ต U+1F1F5 + await new Promise((r) => setTimeout(r, 10)) + + expect(events).toHaveLength(2) + expect(events[0].name).toBe("๐Ÿ‡บ๐Ÿ‡ธ") + expect(events[1].name).toBe("๐Ÿ‡ฏ๐Ÿ‡ต") + handler.destroy() +}) + +test("KeyHandler - Kitty emoji reassembly: modifiers prevent buffering", () => { + const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 }) + const events: KeyEvent[] = [] + handler.on("keypress", (key: KeyEvent) => events.push(key)) + + handler.processInput("\x1b[128512;5u") // Ctrl+๐Ÿ˜€ + + expect(events).toHaveLength(1) + expect(events[0].name).toBe("๐Ÿ˜€") + expect(events[0].ctrl).toBe(true) + handler.destroy() +}) + +test("KeyHandler - Kitty emoji reassembly: subdivision flag", async () => { + const handler = new InternalKeyHandler({ useKittyKeyboard: true, emojiBufferTimeout: 5 }) + const events: KeyEvent[] = [] + handler.on("keypress", (key: KeyEvent) => events.push(key)) + + handler.processInput("\x1b[127988u") // ๐Ÿด U+1F3F4 + handler.processInput("\x1b[917607u") // Tag g U+E0067 + handler.processInput("\x1b[917602u") // Tag b U+E0062 + handler.processInput("\x1b[917605u") // Tag e U+E0065 + handler.processInput("\x1b[917614u") // Tag n U+E006E + handler.processInput("\x1b[917607u") // Tag g U+E0067 + handler.processInput("\x1b[917631u") // Tag cancel U+E007F + await new Promise((r) => setTimeout(r, 10)) + + expect(events).toHaveLength(1) + expect(events[0].name).toBe("๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ") + handler.destroy() +}) diff --git a/packages/core/src/lib/KeyHandler.ts b/packages/core/src/lib/KeyHandler.ts index 2201ec13e..8c859f946 100644 --- a/packages/core/src/lib/KeyHandler.ts +++ b/packages/core/src/lib/KeyHandler.ts @@ -1,6 +1,35 @@ import { EventEmitter } from "events" import { parseKeypress, type KeyEventType, type ParsedKey } from "./parse.keypress" -import { ANSI } from "../ansi" +import { getGraphemeSegmenter } from "./grapheme-segmenter" + +function isGraphemeExtender(codepoint: number): boolean { + return ( + codepoint === 0x200d || // ZWJ + (codepoint >= 0xfe00 && codepoint <= 0xfe0f) || // Variation Selectors + (codepoint >= 0x1f3fb && codepoint <= 0x1f3ff) || // Skin Tone Modifiers + (codepoint >= 0x1f1e6 && codepoint <= 0x1f1ff) || // Regional Indicators (can extend other RI) + codepoint === 0x20e3 || // Combining Enclosing Keycap + (codepoint >= 0xe0020 && codepoint <= 0xe007f) // Tag characters + ) +} + +function canStartGraphemeCluster(codepoint: number): boolean { + return ( + (codepoint >= 0x1f1e6 && codepoint <= 0x1f1ff) || // Regional Indicators + (codepoint >= 0x1f300 && codepoint <= 0x1faff) || // Emoji ranges + codepoint === 0x1f3f4 || // Black Flag (for subdivision flags) + codepoint === 0x23 || // # for keycap + codepoint === 0x2a || // * for keycap + (codepoint >= 0x30 && codepoint <= 0x39) || // 0-9 for keycaps + (codepoint >= 0x2600 && codepoint <= 0x27bf) // Misc Symbols & Dingbats + ) +} + +type EmojiBuffer = { + codepoints: number[] + rawSequences: string[] + baseParsedKey: ParsedKey +} export class KeyEvent implements ParsedKey { name: string @@ -74,12 +103,120 @@ export type KeyHandlerEventMap = { paste: [PasteEvent] } +export type KeyHandlerOptions = { + useKittyKeyboard?: boolean + emojiBufferTimeout?: number +} + export class KeyHandler extends EventEmitter { protected useKittyKeyboard: boolean + private emojiBuffer: EmojiBuffer | null = null + private emojiTimeout: Timer | null = null + private readonly emojiBufferTimeoutMs: number - constructor(useKittyKeyboard: boolean = false) { + constructor(options: KeyHandlerOptions | boolean = false) { super() - this.useKittyKeyboard = useKittyKeyboard + if (typeof options === "boolean") { + this.useKittyKeyboard = options + this.emojiBufferTimeoutMs = 10 + } else { + this.useKittyKeyboard = options.useKittyKeyboard ?? false + this.emojiBufferTimeoutMs = options.emojiBufferTimeout ?? 10 + } + } + + private getCodepointFromKittyKey(parsedKey: ParsedKey): number | null { + if (parsedKey.source !== "kitty") return null + if (parsedKey.name.length === 0) return null + + const codepoint = parsedKey.name.codePointAt(0) + if (codepoint === undefined) return null + if (parsedKey.name.length !== String.fromCodePoint(codepoint).length) return null + + return codepoint + } + + private shouldBufferForEmoji(parsedKey: ParsedKey): boolean { + if (parsedKey.source !== "kitty") return false + if (parsedKey.eventType !== "press") return false + if (parsedKey.ctrl || parsedKey.meta || parsedKey.super || parsedKey.hyper) return false + + const codepoint = this.getCodepointFromKittyKey(parsedKey) + if (codepoint === null) return false + + if (this.emojiBuffer !== null) { + if (isGraphemeExtender(codepoint)) return true + const lastCp = this.emojiBuffer.codepoints[this.emojiBuffer.codepoints.length - 1]! + const ZWJ = 0x200d + if (lastCp === ZWJ) return true + return false + } + + return canStartGraphemeCluster(codepoint) + } + + private bufferEmojiCodepoint(parsedKey: ParsedKey, rawSequence: string): void { + const codepoint = this.getCodepointFromKittyKey(parsedKey)! + + if (this.emojiBuffer === null) { + this.emojiBuffer = { + codepoints: [codepoint], + rawSequences: [rawSequence], + baseParsedKey: parsedKey, + } + } else { + this.emojiBuffer.codepoints.push(codepoint) + this.emojiBuffer.rawSequences.push(rawSequence) + } + + this.scheduleEmojiFlush() + } + + private scheduleEmojiFlush(): void { + if (this.emojiTimeout) { + clearTimeout(this.emojiTimeout) + } + this.emojiTimeout = setTimeout(() => { + this.flushEmojiBuffer() + }, this.emojiBufferTimeoutMs) + } + + private assembleGraphemes(codepoints: number[]): string[] { + const text = String.fromCodePoint(...codepoints) + return [...getGraphemeSegmenter().segment(text)].map((seg) => seg.segment) + } + + public flushEmojiBuffer(): void { + if (this.emojiTimeout) { + clearTimeout(this.emojiTimeout) + this.emojiTimeout = null + } + + if (this.emojiBuffer === null) return + + const { codepoints, rawSequences, baseParsedKey } = this.emojiBuffer + this.emojiBuffer = null + + const graphemes = this.assembleGraphemes(codepoints) + + for (const grapheme of graphemes) { + const keyEvent: ParsedKey = { + ...baseParsedKey, + name: grapheme, + sequence: grapheme, + raw: rawSequences.join(""), + } + + try { + if (keyEvent.eventType === "press") { + this.emit("keypress", new KeyEvent(keyEvent)) + } else { + this.emit("keyrelease", new KeyEvent(keyEvent)) + } + } catch (error) { + console.error(`[KeyHandler] Error emitting buffered emoji:`, error) + } + } } public processInput(data: string): boolean { @@ -89,6 +226,13 @@ export class KeyHandler extends EventEmitter { return false } + if (this.shouldBufferForEmoji(parsedKey)) { + this.bufferEmojiCodepoint(parsedKey, data) + return true + } + + this.flushEmojiBuffer() + try { switch (parsedKey.eventType) { case "press": @@ -117,17 +261,21 @@ export class KeyHandler extends EventEmitter { console.error(`[KeyHandler] Error processing paste:`, error) } } + + public destroy(): void { + if (this.emojiTimeout) { + clearTimeout(this.emojiTimeout) + this.emojiTimeout = null + } + this.emojiBuffer = null + } } -/** - * This class is used internally by the renderer to ensure global handlers - * can preventDefault before renderable handlers process events. - */ export class InternalKeyHandler extends KeyHandler { private renderableHandlers: Map> = new Map() - constructor(useKittyKeyboard: boolean = false) { - super(useKittyKeyboard) + constructor(options: KeyHandlerOptions | boolean = false) { + super(options) } public emit(event: K, ...args: KeyHandlerEventMap[K]): boolean { @@ -144,8 +292,6 @@ export class InternalKeyHandler extends KeyHandler { } const renderableSet = this.renderableHandlers.get(event) - // Snapshot the handler list so listeners added during dispatch (e.g., via focus changes) - // do not receive the in-flight key event. const renderableHandlers = renderableSet && renderableSet.size > 0 ? [...renderableSet] : [] let hasRenderableListeners = false @@ -188,4 +334,9 @@ export class InternalKeyHandler extends KeyHandler { handlers.delete(handler) } } + + public override destroy(): void { + super.destroy() + this.renderableHandlers.clear() + } } From b68c74285f2a8fee34814787026fa8f90d2fff3e Mon Sep 17 00:00:00 2001 From: GreyElaina Date: Wed, 7 Jan 2026 12:45:13 +0800 Subject: [PATCH 3/6] docs(core): add Unicode spec references (UAX #29, UTS #51) to emoji helpers --- packages/core/src/lib/KeyHandler.ts | 29 +- .../tests/__snapshots__/layout.test.ts.snap | 183 +++++++++++ .../tests/__snapshots__/textarea.test.ts.snap | 285 ++++++++++++++++++ 3 files changed, 484 insertions(+), 13 deletions(-) create mode 100644 packages/vue/tests/__snapshots__/layout.test.ts.snap create mode 100644 packages/vue/tests/__snapshots__/textarea.test.ts.snap diff --git a/packages/core/src/lib/KeyHandler.ts b/packages/core/src/lib/KeyHandler.ts index 8c859f946..15a3f0adb 100644 --- a/packages/core/src/lib/KeyHandler.ts +++ b/packages/core/src/lib/KeyHandler.ts @@ -2,26 +2,29 @@ import { EventEmitter } from "events" import { parseKeypress, type KeyEventType, type ParsedKey } from "./parse.keypress" import { getGraphemeSegmenter } from "./grapheme-segmenter" +// Grapheme cluster extenders per UAX #29 (https://unicode.org/reports/tr29/) +// and emoji sequences per UTS #51 (https://unicode.org/reports/tr51/) function isGraphemeExtender(codepoint: number): boolean { return ( - codepoint === 0x200d || // ZWJ - (codepoint >= 0xfe00 && codepoint <= 0xfe0f) || // Variation Selectors - (codepoint >= 0x1f3fb && codepoint <= 0x1f3ff) || // Skin Tone Modifiers - (codepoint >= 0x1f1e6 && codepoint <= 0x1f1ff) || // Regional Indicators (can extend other RI) - codepoint === 0x20e3 || // Combining Enclosing Keycap - (codepoint >= 0xe0020 && codepoint <= 0xe007f) // Tag characters + codepoint === 0x200d || // ZWJ (Grapheme_Cluster_Break=ZWJ) + (codepoint >= 0xfe00 && codepoint <= 0xfe0f) || // Variation Selectors (Grapheme_Cluster_Break=Extend) + (codepoint >= 0x1f3fb && codepoint <= 0x1f3ff) || // Emoji Modifiers (Emoji_Modifier=Yes โ†’ Extend) + (codepoint >= 0x1f1e6 && codepoint <= 0x1f1ff) || // Regional Indicators (form RI pairs) + codepoint === 0x20e3 || // Combining Enclosing Keycap (emoji_keycap_sequence) + (codepoint >= 0xe0020 && codepoint <= 0xe007f) // Tag Characters (Grapheme_Cluster_Break=Extend) ) } +// Codepoints that can start multi-codepoint emoji sequences per UTS #51 function canStartGraphemeCluster(codepoint: number): boolean { return ( - (codepoint >= 0x1f1e6 && codepoint <= 0x1f1ff) || // Regional Indicators - (codepoint >= 0x1f300 && codepoint <= 0x1faff) || // Emoji ranges - codepoint === 0x1f3f4 || // Black Flag (for subdivision flags) - codepoint === 0x23 || // # for keycap - codepoint === 0x2a || // * for keycap - (codepoint >= 0x30 && codepoint <= 0x39) || // 0-9 for keycaps - (codepoint >= 0x2600 && codepoint <= 0x27bf) // Misc Symbols & Dingbats + (codepoint >= 0x1f1e6 && codepoint <= 0x1f1ff) || // Regional Indicators (emoji_flag_sequence) + (codepoint >= 0x1f300 && codepoint <= 0x1faff) || // Emoji ranges (Emoji=Yes) + codepoint === 0x1f3f4 || // Black Flag (emoji_tag_sequence base) + codepoint === 0x23 || // # (emoji_keycap_sequence) + codepoint === 0x2a || // * (emoji_keycap_sequence) + (codepoint >= 0x30 && codepoint <= 0x39) || // 0-9 (emoji_keycap_sequence) + (codepoint >= 0x2600 && codepoint <= 0x27bf) // Misc Symbols & Dingbats (Emoji=Yes) ) } diff --git a/packages/vue/tests/__snapshots__/layout.test.ts.snap b/packages/vue/tests/__snapshots__/layout.test.ts.snap new file mode 100644 index 000000000..cfcb623bd --- /dev/null +++ b/packages/vue/tests/__snapshots__/layout.test.ts.snap @@ -0,0 +1,183 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`Vue Renderer | Layout Tests Basic Text Rendering should render simple text correctly 1`] = ` +"Hello World + + + + +" +`; + +exports[`Vue Renderer | Layout Tests Basic Text Rendering should render multiline text correctly 1`] = ` +"Line 1 +Line 2 +Line 3 + + +" +`; + +exports[`Vue Renderer | Layout Tests Basic Text Rendering should render text with dynamic content 1`] = ` +"Counter: 42 + + +" +`; + +exports[`Vue Renderer | Layout Tests Box Layout Rendering should render basic box layout correctly 1`] = ` +"โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚Inside Box โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + + +" +`; + +exports[`Vue Renderer | Layout Tests Box Layout Rendering should render nested boxes correctly 1`] = ` +"โ”Œโ”€Parent Boxโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚Nested โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ Sibling โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + +" +`; + +exports[`Vue Renderer | Layout Tests Box Layout Rendering should render absolute positioned boxes 1`] = ` +"โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚Box 1 โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚Box 2 โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + + +" +`; + +exports[`Vue Renderer | Layout Tests Box Layout Rendering should auto-enable border when borderStyle is set 1`] = ` +"โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚With Border โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + + +" +`; + +exports[`Vue Renderer | Layout Tests Box Layout Rendering should auto-enable border when borderColor is set 1`] = ` +"โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚Colored Border โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + + +" +`; + +exports[`Vue Renderer | Layout Tests Complex Layouts should render complex nested layout correctly 1`] = ` +"โ”Œโ”€Complex Layoutโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚Header Sectioโ”‚ โ”‚ +โ”‚ โ”‚Menu Item 1 โ”‚ โ”‚ +โ”‚ โ”‚Menu Item 2 โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚Content Area โ”‚ โ”‚ +โ”‚ โ”‚Some content herโ”‚ โ”‚ +โ”‚ โ”‚More content โ”‚ โ”‚ +โ”‚ โ”‚Footer text โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ Status: Ready โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + +" +`; + +exports[`Vue Renderer | Layout Tests Complex Layouts should render text with mixed styling and layout 1`] = ` +"โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Something went wrong โ”‚ +โ”‚ Check your settings โ”‚ +โ”‚ All systems operational โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + +" +`; + +exports[`Vue Renderer | Layout Tests Complex Layouts should render scrollbox with sticky scroll and spacer 1`] = ` +"โ”Œโ”€scroll areaโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ โ”‚ +โ”‚โ”Œโ”€hiโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”‚ +โ”‚โ”‚ โ”‚โ”‚ +โ”‚โ”‚ โ”‚โ”‚ +โ”‚โ”‚ โ”‚โ”‚ +โ”‚โ”‚ โ”‚โ”‚ +โ”‚โ”‚ โ”‚โ”‚ +โ”‚โ”‚ โ”‚โ”‚ +โ”‚โ”‚ โ”‚โ”‚ +โ”‚โ”‚ โ”‚โ”‚ +โ”‚โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +โ”Œโ”€spacerโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚spacer โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +" +`; + +exports[`Vue Renderer | Layout Tests Empty and Edge Cases should handle empty component 1`] = ` +" + + + + +" +`; + +exports[`Vue Renderer | Layout Tests Empty and Edge Cases should handle component with no children 1`] = ` +" + + + + + + + +" +`; + +exports[`Vue Renderer | Layout Tests Empty and Edge Cases should handle very small dimensions 1`] = ` +"Hi + + +" +`; diff --git a/packages/vue/tests/__snapshots__/textarea.test.ts.snap b/packages/vue/tests/__snapshots__/textarea.test.ts.snap new file mode 100644 index 000000000..9bcc539b6 --- /dev/null +++ b/packages/vue/tests/__snapshots__/textarea.test.ts.snap @@ -0,0 +1,285 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`Vue Renderer | Textarea Layout Tests Basic Textarea Rendering should render simple textarea correctly 1`] = ` +"Hello World + + + + + + + + + +" +`; + +exports[`Vue Renderer | Textarea Layout Tests Basic Textarea Rendering should render multiline textarea content 1`] = ` +"Line 1 +Line 2 +Line 3 + + + + + + + + + + + + +" +`; + +exports[`Vue Renderer | Textarea Layout Tests Basic Textarea Rendering should render textarea with word wrapping 1`] = ` +"This is a very long +line that should +wrap to multiple +lines when word +wrapping is enabled + + + + + + + + + + +" +`; + +exports[`Vue Renderer | Textarea Layout Tests Basic Textarea Rendering should render textarea with placeholder 1`] = ` +"Type something here... + + + + + + + + + +" +`; + +exports[`Vue Renderer | Textarea Layout Tests Prompt-like Layout should render textarea in prompt-style layout with indicator 1`] = ` +"โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ โ”‚ +โ”‚ > Hello from the prompt โ”‚ +โ”‚ โ”‚ +โ”‚ ctrl+p commandsโ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + + + + + + + + +" +`; + +exports[`Vue Renderer | Textarea Layout Tests Prompt-like Layout should render textarea with long wrapping text in prompt layout 1`] = ` +"โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ โ”‚ +โ”‚ This is a very long prompt that will wrap โ”‚ +โ”‚ > across multiple lines in the textarea. It โ”‚ +โ”‚ should maintain proper layout with the โ”‚ +โ”‚ indicator on the left. โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + + + + + + + + + + +" +`; + +exports[`Vue Renderer | Textarea Layout Tests Prompt-like Layout should render textarea in shell mode with different indicator 1`] = ` +"โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ โ”‚ +โ”‚ ! ls -la โ”‚ +โ”‚ โ”‚ +โ”‚shell mode โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + + + + + +" +`; + +exports[`Vue Renderer | Textarea Layout Tests Complex Layouts with Multiple Textareas should render multiple textareas in a column layout 1`] = ` +"โ”Œโ”€Chatโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”‚ +โ”‚โ”‚User What is the weather like today? โ”‚โ”‚ +โ”‚โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ”‚ +โ”‚ โ”‚ +โ”‚โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”‚ +โ”‚โ”‚AI I don't have access to real-time weather data, โ”‚โ”‚ +โ”‚โ”‚ but I can help you find that information through โ”‚โ”‚ +โ”‚โ”‚ various weather services. โ”‚โ”‚ +โ”‚โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + + + + + + + + + + + + + +" +`; + +exports[`Vue Renderer | Textarea Layout Tests Complex Layouts with Multiple Textareas should handle nested boxes with textareas at different positions 1`] = ` +"โ”Œโ”€Layout Testโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”‚ +โ”‚โ”‚Input 1: โ”‚ โ”‚Input 2: โ”‚โ”‚ +โ”‚โ”‚Left panel contentโ”‚ โ”‚Right panel with longer โ”‚โ”‚ +โ”‚โ”‚ โ”‚ โ”‚content that may wrap โ”‚โ”‚ +โ”‚โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ”‚ +โ”‚ โ”‚ +โ”‚โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”‚ +โ”‚โ”‚Bottom input: โ”‚โ”‚ +โ”‚โ”‚Bottom panel spanning full width โ”‚โ”‚ +โ”‚โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + + + + + + + + + + + + +" +`; + +exports[`Vue Renderer | Textarea Layout Tests FlexShrink Regression Tests should not shrink box when width is set via setter 1`] = ` +"โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚> Content that takes up โ”‚ +โ”‚ space โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +" +`; + +exports[`Vue Renderer | Textarea Layout Tests FlexShrink Regression Tests should not shrink box when height is set via setter in column layout 1`] = ` +"โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚Header โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚Line1 โ”‚ +โ”‚Line2 โ”‚ +โ”‚Line3 โ”‚ +โ”‚Footer โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + + + + +" +`; + +exports[`Vue Renderer | Textarea Layout Tests Edge Cases and Styling should render textarea with focused colors 1`] = ` +"โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚> โ”‚ +โ”‚ Focused textarea โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + + + + +" +`; + +exports[`Vue Renderer | Textarea Layout Tests Edge Cases and Styling should render empty textarea with placeholder in prompt layout 1`] = ` +"โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ โ”‚ +โ”‚ > Enter your prompt here... โ”‚ +โ”‚ โ”‚ +โ”‚Ready to chat โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + + + + + +" +`; + +exports[`Vue Renderer | Textarea Layout Tests Edge Cases and Styling should render textarea with very long single line 1`] = ` +"โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚> โ”‚ +โ”‚ ThisIsAVeryLongLineWithNoSpacesThatโ”‚ +โ”‚ WillWrapByCharacterWhenCharWrappingโ”‚ +โ”‚ IsEnabled โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + + + + + + + +" +`; + +exports[`Vue Renderer | Textarea Layout Tests Edge Cases and Styling should render full prompt-like layout with all components 1`] = ` +"โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ โ”‚ +โ”‚ > Explain how async/await works in JavaScript and provide some โ”‚ +โ”‚ examples โ”‚ +โ”‚ โ”‚ +โ”‚ ctrl+p โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +Tip: Use arrow keys to navigate through history when cursor is at the +start + + + + + + + + + + +" +`; From b0a0d66b20d5ba41eea29a946d91625765dbed20 Mon Sep 17 00:00:00 2001 From: GreyElaina Date: Thu, 8 Jan 2026 08:52:48 +0800 Subject: [PATCH 4/6] fix(core): handle surrogate edge cases and preserve polyfill errors - Reject lone surrogates in firstGrapheme fast path (fall through to segmenter) - Preserve original error message when polyfill loading fails --- packages/core/src/lib/grapheme-segmenter.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/lib/grapheme-segmenter.ts b/packages/core/src/lib/grapheme-segmenter.ts index 2b23567f8..6604718c0 100644 --- a/packages/core/src/lib/grapheme-segmenter.ts +++ b/packages/core/src/lib/grapheme-segmenter.ts @@ -10,8 +10,9 @@ function initializePolyfill(): Promise { try { await import("@formatjs/intl-segmenter/polyfill-force.js") } catch (e) { + const message = e instanceof Error ? e.message : String(e) initError = new Error( - "Failed to load Intl.Segmenter polyfill. Please ensure @formatjs/intl-segmenter is installed or use a runtime that supports Intl.Segmenter natively.", + `Failed to load Intl.Segmenter polyfill: ${message}. Please ensure @formatjs/intl-segmenter is installed or use a runtime that supports Intl.Segmenter natively.`, ) } } @@ -70,7 +71,8 @@ export function firstGrapheme(str: string): string { if (str.length === 1) return str[0]! const secondCode = str.charCodeAt(1) if (secondCode < 128) return str[0]! - } else if (str.length === 1) { + } else if (str.length === 1 && (firstCode < 0xd800 || firstCode > 0xdfff)) { + // Single non-surrogate codepoint return str[0]! } else if (isHighSurrogate(firstCode)) { const secondCode = str.charCodeAt(1) From 4f580bbd1603b6af776a8114b07e38387be31d70 Mon Sep 17 00:00:00 2001 From: GreyElaina Date: Thu, 8 Jan 2026 13:17:03 +0800 Subject: [PATCH 5/6] feat(core): use isSingleGrapheme in parse.keypress for proper emoji/CJK detection Update parse.keypress.ts to use isSingleGrapheme() instead of s.length === 1 for proper handling of multi-codepoint characters like emoji and CJK. --- packages/core/src/lib/parse.keypress.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/lib/parse.keypress.ts b/packages/core/src/lib/parse.keypress.ts index a11f91cf3..845e6490f 100644 --- a/packages/core/src/lib/parse.keypress.ts +++ b/packages/core/src/lib/parse.keypress.ts @@ -1,6 +1,7 @@ // Copied from https://github.com/enquirer/enquirer/blob/36785f3399a41cd61e9d28d1eb9c2fcd73d69b4c/lib/keypress.js import { Buffer } from "node:buffer" import { parseKittyKeyboard } from "./parse.keypress-kitty" +import { isSingleGrapheme } from "./grapheme-segmenter" const metaKeyCodeRe = /^(?:\x1b)([a-zA-Z0-9])$/ @@ -298,8 +299,8 @@ export const parseKeypress = (s: Buffer | string = "", options: ParseKeypressOpt // shift+letter key.name = s.toLowerCase() key.shift = true - } else if (s.length === 1) { - // Special characters (like $, ^, etc.) - preserve the character + } else if (isSingleGrapheme(s)) { + // Single grapheme: special characters, emoji, CJK, etc. key.name = s } else if ((parts = metaKeyCodeRe.exec(s))) { // meta+character key From 09d008ad6f5e483663ba4e42c49d1ec6173e2506 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Mon, 12 Jan 2026 23:15:02 +0100 Subject: [PATCH 6/6] bun exclusive --- packages/core/src/lib/grapheme-segmenter.ts | 38 ++------------------- 1 file changed, 2 insertions(+), 36 deletions(-) diff --git a/packages/core/src/lib/grapheme-segmenter.ts b/packages/core/src/lib/grapheme-segmenter.ts index 6604718c0..21ccb8e92 100644 --- a/packages/core/src/lib/grapheme-segmenter.ts +++ b/packages/core/src/lib/grapheme-segmenter.ts @@ -1,43 +1,9 @@ let segmenter: Intl.Segmenter | null = null -let initPromise: Promise | null = null -let initError: Error | null = null - -function initializePolyfill(): Promise { - if (initPromise) return initPromise - - initPromise = (async () => { - if (typeof Intl === "undefined" || typeof (Intl as any).Segmenter !== "function") { - try { - await import("@formatjs/intl-segmenter/polyfill-force.js") - } catch (e) { - const message = e instanceof Error ? e.message : String(e) - initError = new Error( - `Failed to load Intl.Segmenter polyfill: ${message}. Please ensure @formatjs/intl-segmenter is installed or use a runtime that supports Intl.Segmenter natively.`, - ) - } - } - })() - - return initPromise -} - -initializePolyfill() export function getGraphemeSegmenter(): Intl.Segmenter { if (segmenter) return segmenter - - if (typeof Intl !== "undefined" && typeof (Intl as any).Segmenter === "function") { - segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }) - return segmenter - } - - if (initError) { - throw initError - } - - throw new Error( - "Intl.Segmenter is not available. Please ensure your runtime supports it or install @formatjs/intl-segmenter", - ) + segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }) + return segmenter } function isHighSurrogate(code: number): boolean {