From 65654afac07e874df3342913e83781bce5a12a2c Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 10 Feb 2026 15:34:02 -0500 Subject: [PATCH] fix(client): optimize iOS text presentation handling --- src/client/__tests__/useTerminal.test.tsx | 49 ++++++++++++++++++++++- src/client/hooks/useTerminal.ts | 27 ++++++++----- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/src/client/__tests__/useTerminal.test.tsx b/src/client/__tests__/useTerminal.test.tsx index 2a1aac5..e30340e 100644 --- a/src/client/__tests__/useTerminal.test.tsx +++ b/src/client/__tests__/useTerminal.test.tsx @@ -413,7 +413,7 @@ describe('useTerminal', () => { }) // Output is wrapped in synchronized output sequences (BSU/ESU) - expect(terminal.writes).toEqual([`\x1b[?2026hx\u23FA\uFE0Ey\x1b[?2026l`]) + expect(terminal.writes).toEqual([`\x1b[?2026hx\u23FAy\x1b[?2026l`]) terminal.selection = '' @@ -444,6 +444,53 @@ describe('useTerminal', () => { expect(sendCalls.some((call) => call.type === 'terminal-resize')).toBe(true) }) + test('adds text presentation selectors to output on iOS only', async () => { + globalAny.navigator = { + userAgent: 'iPhone', + platform: 'iPhone', + maxTouchPoints: 0, + clipboard: { writeText: () => Promise.resolve() }, + } as unknown as Navigator + + const listeners: Array<(message: ServerMessage) => void> = [] + const { container } = createContainerMock() + + await act(async () => { + TestRenderer.create( + {}} + subscribe={(listener) => { + listeners.push(listener) + return () => {} + }} + theme={{ background: '#000' }} + fontSize={12} + onScrollChange={() => {}} + />, + { + createNodeMock: () => container, + } + ) + }) + + const terminal = TerminalMock.instances[0] + if (!terminal) { + throw new Error('Expected terminal instance') + } + + act(() => { + listeners[0]?.({ + type: 'terminal-output', + sessionId: 'session-1', + data: `x\u23FAy`, + }) + }) + + expect(terminal.writes).toEqual([`\x1b[?2026hx\u23FA\uFE0Ey\x1b[?2026l`]) + }) + test('detaches previous session and cleans up on unmount', async () => { globalAny.navigator = { userAgent: 'Chrome', diff --git a/src/client/hooks/useTerminal.ts b/src/client/hooks/useTerminal.ts index af36069..bb85e6e 100644 --- a/src/client/hooks/useTerminal.ts +++ b/src/client/hooks/useTerminal.ts @@ -84,23 +84,30 @@ const TEXT_VS = '\uFE0E' // Characters that iOS Safari renders as emoji but should be text // Only add characters here that are verified to cause issues -const EMOJI_TO_TEXT_CHARS = new Set([ +const EMOJI_TO_TEXT_CHARS = [ '\u23FA', // ⏺ Black Circle for Record (Claude's bullet) -]) +] as const +const escapeRegExp = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +const EMOJI_TO_TEXT_REGEX = new RegExp( + EMOJI_TO_TEXT_CHARS.map(escapeRegExp).join('|'), + 'g' +) /** * Add text presentation selector after characters that iOS renders as emoji. * This forces the browser to render them as text glyphs instead. */ export function forceTextPresentation(data: string): string { - let result = '' - for (const char of data) { - result += char - if (EMOJI_TO_TEXT_CHARS.has(char)) { - result += TEXT_VS + // Hot path: most terminal output doesn't contain emoji-like glyphs. + // Avoid per-character concatenation (expensive) and skip work when not needed. + for (const char of EMOJI_TO_TEXT_CHARS) { + if (data.indexOf(char) === -1) { + continue } + return data.replace(EMOJI_TO_TEXT_REGEX, `$&${TEXT_VS}`) } - return result + return data } interface UseTerminalOptions { @@ -895,7 +902,9 @@ export function useTerminal({ attachedSession && message.sessionId === attachedSession ) { - outputBufferRef.current += forceTextPresentation(message.data) + outputBufferRef.current += isiOS + ? forceTextPresentation(message.data) + : message.data scheduleFlush() }