Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion src/client/__tests__/useTerminal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ''

Expand Down Expand Up @@ -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(
<TerminalHarness
sessionId="session-1"
tmuxTarget="agentboard:@1"
sendMessage={() => {}}
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',
Expand Down
27 changes: 18 additions & 9 deletions src/client/hooks/useTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}

Expand Down
Loading