Skip to content
Open
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
10 changes: 10 additions & 0 deletions mobile/scripts/build-terminal-webview-engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ async function buildEngineJs() {
import { Terminal } from '@xterm/xterm'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebglAddon } from '@xterm/addon-webgl'
import {
activateOrcaTerminalUnicodeProvider,
setTerminalEastAsianAmbiguousWidthMode
} from '../src/shared/terminal-unicode-provider.ts'

// Why: xterm reaches for these runtime APIs on the terminal-bringup path,
// and esbuild lowers syntax but not runtime APIs. Guarded shims let the
Expand Down Expand Up @@ -62,6 +66,12 @@ async function buildEngineJs() {
window.Terminal = Terminal
window.Unicode11Addon = { Unicode11Addon }
window.WebglAddon = { WebglAddon }
// Why: mobile previously loaded Unicode11 only and bypassed Orca's ZWJ + EAW
// width provider used by desktop/headless (#9958).
window.OrcaTerminalUnicode = {
activate: activateOrcaTerminalUnicodeProvider,
setEastAsianAmbiguousWidthMode: setTerminalEastAsianAmbiguousWidthMode
}
`,
resolveDir: mobileRoot,
sourcefile: 'terminal-webview-engine-entry.js'
Expand Down
14 changes: 12 additions & 2 deletions mobile/src/terminal/TerminalWebView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
style,
terminalTheme,
textScale = 1,
eastAsianAmbiguousWidth = 'narrow',
onWebReady,
onEngineError,
onSelectionMode,
Expand Down Expand Up @@ -280,7 +281,8 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
oscLinks,
terminalTheme,
fontScale: textScale,
preserveScroll
preserveScroll,
eastAsianAmbiguousWidth
})
},
resize(cols: number, rows: number) {
Expand Down Expand Up @@ -360,7 +362,15 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
})
}
}),
[armWebReadyWatchdog, postMessage, sendToWebView, terminalTheme, textScale, writeCoalescer]
[
armWebReadyWatchdog,
eastAsianAmbiguousWidth,
postMessage,
sendToWebView,
terminalTheme,
textScale,
writeCoalescer
]
)

return (
Expand Down
2 changes: 2 additions & 0 deletions mobile/src/terminal/terminal-webview-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export type TerminalWebViewProps = {
// Why: baseline zoom multiplier applied on top of fit-to-width scale; raw
// xterm fontSize alone cannot drive apparent size because fitting cancels it.
textScale?: number
/** Desktop Settings twin for East Asian Ambiguous width (#9958); default narrow. */
eastAsianAmbiguousWidth?: 'narrow' | 'wide'
onWebReady?: () => void
onEngineError?: (message: string) => void
} & TerminalSelectionEvents
Expand Down
38 changes: 38 additions & 0 deletions mobile/src/terminal/terminal-webview-eaw-injected.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// East Asian Ambiguous width + Orca unicode activation for the mobile WebView IIFE (#9958).
// Mirrors desktop/headless: Unicode11 base + Orca provider, optional wide mode CJK fonts.

export const TERMINAL_EAW_JS = `
// Why: wide EAW mode needs full-em CJK glyphs ahead of Consolas (#9958 desktop parity).
var CJK_AMBIGUOUS_FALLBACKS = '"Malgun Gothic", "Microsoft YaHei", "Yu Gothic", "Apple SD Gothic Neo", "PingFang SC", "Hiragino Sans", "Noto Sans CJK KR", "Noto Sans CJK SC", "Noto Sans CJK JP"';
// Why: process-wide for this WebView document; set before first write via init.
var eastAsianAmbiguousWidthMode = 'narrow';
function buildTerminalFontFamily() {
var lead = isIOSWebView() ? 'ui-monospace, ' : '"SF Mono", ';
if (eastAsianAmbiguousWidthMode === 'wide') {
// Why: insert CJK before Consolas so ambiguous glyphs fill the two-cell slot.
return lead + '"Menlo", "Monaco", "Cascadia Mono", ' + CJK_AMBIGUOUS_FALLBACKS + ', "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", monospace';
}
return lead + TERMINAL_FONT_FALLBACKS;
}
function applyEastAsianAmbiguousWidthMode(nextMode) {
if (nextMode === 'wide' || nextMode === 'narrow') {
eastAsianAmbiguousWidthMode = nextMode;
}
}
function activateOrcaUnicodeOnTerm(termInstance) {
// Why: load Unicode11 then Orca's ZWJ + East Asian Ambiguous provider (desktop/headless parity).
// Previously mobile stopped at Unicode11 and bypassed the Orca width tables entirely.
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) {
try {
termInstance.loadAddon(new window.Unicode11Addon.Unicode11Addon());
termInstance.unicode.activeVersion = '11';
} catch (e) {}
}
if (window.OrcaTerminalUnicode) {
try {
window.OrcaTerminalUnicode.setEastAsianAmbiguousWidthMode(eastAsianAmbiguousWidthMode);
window.OrcaTerminalUnicode.activate(termInstance);
} catch (e) {}
}
}
`
11 changes: 8 additions & 3 deletions mobile/src/terminal/terminal-webview-html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected'
import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected'
import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
import { TERMINAL_EAW_JS } from './terminal-webview-eaw-injected'
import { TERMINAL_WHEEL_SCROLL_JS } from './terminal-webview-wheel-scroll-injected'

const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = {
Expand Down Expand Up @@ -258,6 +259,7 @@ window.onerror = function(msg) {
// fall to a non-monospace face; lead with the ui-monospace generic to avoid that.
var TERMINAL_FONT_FALLBACKS = '"Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", monospace';
var terminalFontFamily = (isIOSWebView() ? 'ui-monospace, ' : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS;
${TERMINAL_EAW_JS}
// Why: change the real font size, then resize the grid to fit the viewport at
// the new cell metrics so the text shows at its true size immediately. RN's
// refit (measure → updateViewport) then makes the server reflow the PTY to the
Expand Down Expand Up @@ -667,8 +669,10 @@ ${TERMINAL_WEBVIEW_THEME_JS}

${TERMINAL_WEBGL_RECOVERY_JS}

function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) {
function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks, nextEastAsianAmbiguousWidth) {
if (typeof nextFontScale === 'number' && nextFontScale > 0) currentTextScale = nextFontScale;
// Why: host Settings → Ambiguous Character Width must land before the first write (#9958).
applyEastAsianAmbiguousWidthMode(nextEastAsianAmbiguousWidth);
// Why: a width-reflow re-stream rewraps the same content at new cols.
// Distance-from-bottom (rows) is the only stable anchor across reflow,
// since line counts and cell positions change. null = stay pinned to bottom.
Expand Down Expand Up @@ -713,6 +717,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
var nextSurface = surfaceSwap.nextSurface;

applyTerminalTheme(nextTheme);
terminalFontFamily = buildTerminalFontFamily();
term = new Terminal({
cols: cols || 80,
rows: rows || 24,
Expand All @@ -738,7 +743,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
pendingTerm = nextTerm;
term.open(surface);
attachWebglAddon(true);
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {}
activateOrcaUnicodeOnTerm(term);
if (typeof replayData === 'string' && replayData.length > 0) {
enqueueWrite(replayData);
}
Expand Down Expand Up @@ -918,7 +923,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
if (msg.type === 'ping') {
notify({ type: 'pong', pingId: msg.id });
} else if (msg.type === 'init') {
init(msg.cols, msg.rows, msg.initialData, msg.terminalTheme, msg.fontScale, msg.preserveScroll, msg.oscLinks);
init(msg.cols, msg.rows, msg.initialData, msg.terminalTheme, msg.fontScale, msg.preserveScroll, msg.oscLinks, msg.eastAsianAmbiguousWidth);
} else if (msg.type === 'set-font-scale') {
// Why: ignore RN echoing back the value a pinch just set (msg.fontScale ===
// currentTextScale) so the post-pinch state isn't reset; only apply changes.
Expand Down
2 changes: 2 additions & 0 deletions mobile/src/terminal/terminal-webview-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type TerminalWebViewCommand =
// Why: width-reflow re-streams replay the same content rewrapped at new
// cols; preserve the reader's scroll position instead of jumping to bottom.
preserveScroll?: boolean
/** Desktop Settings twin for East Asian Ambiguous cell width (#9958). */
eastAsianAmbiguousWidth?: 'narrow' | 'wide'
}
| { type: 'set-font-scale'; id?: number; fontScale: number }
| { type: 'resize'; id?: number; cols: number; rows: number }
Expand Down
16 changes: 11 additions & 5 deletions mobile/src/terminal/terminal-webview-text-zoom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ const terminalWebViewSource = readFileSync(
new URL('./TerminalWebView.tsx', import.meta.url),
'utf8'
)
const terminalEawSource = readFileSync(
new URL('./terminal-webview-eaw-injected.ts', import.meta.url),
'utf8'
)
const terminalHtmlSource = readFileSync(
new URL('./terminal-webview-html.ts', import.meta.url),
'utf8'
Expand Down Expand Up @@ -139,15 +143,17 @@ describe('TerminalWebView text zoom', () => {
)
})

it('loads Unicode 11 before replaying mobile terminal bytes', () => {
it('loads Unicode 11 and Orca width provider before replaying mobile terminal bytes', () => {
expect(terminalHtmlSource).toContain('XTERM_ENGINE_JS')
expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon')
expect(terminalHtmlSource).toContain('activateOrcaUnicodeOnTerm(term)')
expect(terminalEawSource).toContain('window.Unicode11Addon.Unicode11Addon')
expect(terminalEawSource).toContain('window.OrcaTerminalUnicode.activate')
const open = terminalHtmlSource.indexOf('term.open(surface)')
const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'")
const orca = terminalHtmlSource.indexOf('activateOrcaUnicodeOnTerm(term)')
const replay = terminalHtmlSource.indexOf('enqueueWrite(replayData)')
expect(open).toBeGreaterThanOrEqual(0)
expect(unicode).toBeGreaterThan(open)
expect(replay).toBeGreaterThan(unicode)
expect(orca).toBeGreaterThan(open)
expect(replay).toBeGreaterThan(orca)
})

it('uses the bundled WebGL-capable xterm stack and platform-safe font fallbacks', () => {
Expand Down
19 changes: 19 additions & 0 deletions src/main/daemon/headless-emulator-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { TerminalEastAsianAmbiguousWidth } from '../../shared/east-asian-ambiguous-width'

export type HeadlessEmulatorOptions = {
cols: number
rows: number
scrollback?: number
/** Query reply sink (terminal-query-authority.md); only `forwardQueryReplies` writes emit here. The daemon Session must never pass this. */
onQueryReply?: (reply: string) => void
pathFlavor?: 'posix' | 'win32'
remotePosixFileUriAuthority?: boolean
wslDistro?: string
/** Match renderer EAW mode so headless mirrors do not tear under SSH (#9958). */
eastAsianAmbiguousWidth?: TerminalEastAsianAmbiguousWidth
}

export type HeadlessEmulatorWriteOptions = {
/** Reply ownership for this exact chunk; default false so seed/hydration/snapshot writes never forward (main-side replay guard; twin of renderer replay-guard.ts). */
forwardQueryReplies?: boolean
}
30 changes: 29 additions & 1 deletion src/main/daemon/headless-emulator-unicode-width.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { HeadlessEmulator } from './headless-emulator'
import { setTerminalEastAsianAmbiguousWidthMode } from '../../shared/terminal-unicode-provider'

// Why these oracles: agent TUIs position the cursor assuming the widths the
// on-screen xterm uses (Unicode 11 + ZWJ joining). If this mirror measures a
// preceding emoji differently, the positioned write lands on the wrong cell
// and the mirrored row tears — which snapshot restores then paint back.
describe('headless emulator unicode widths', () => {
afterEach(() => {
setTerminalEastAsianAmbiguousWidthMode('narrow')
})

it('advances emoji as two cells so positioned writes land like the renderer', async () => {
const emulator = new HeadlessEmulator({ cols: 40, rows: 4 })
// 🤖 occupies columns 1-2 under Unicode 11 (one cell under the v6 default),
Expand All @@ -25,4 +30,27 @@ describe('headless emulator unicode widths', () => {
expect(emulator.getVisibleLines()[0]).toBe('\u{1F469}\u{200D}\u{1F4BB}Y')
emulator.dispose()
})

// Why: wide mode is the SSH/headless twin of Settings → Ambiguous Character Width (#9958).
it('treats East Asian Ambiguous as two cells when eastAsianAmbiguousWidth is wide', async () => {
const emulator = new HeadlessEmulator({
cols: 40,
rows: 4,
eastAsianAmbiguousWidth: 'wide'
})
// ① is EAW=A (narrow=1 cell); wide budgets two cells so "X" is at column 3.
await emulator.write('\x1b[H\u{2460}X')
await emulator.write('\x1b[1;3HY')
expect(emulator.getVisibleLines()[0]).toBe('\u{2460}Y')
emulator.dispose()
})

it('keeps East Asian Ambiguous one cell when eastAsianAmbiguousWidth is narrow', async () => {
const emulator = new HeadlessEmulator({ cols: 40, rows: 4, eastAsianAmbiguousWidth: 'narrow' })
// narrow: ① col1, X col2 — writing Y at col3 leaves X intact → ①XY
await emulator.write('\x1b[H\u{2460}X')
await emulator.write('\x1b[1;3HY')
expect(emulator.getVisibleLines()[0]).toBe('\u{2460}XY')
emulator.dispose()
})
})
35 changes: 17 additions & 18 deletions src/main/daemon/headless-emulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@ import './xterm-env-polyfill'
import { Terminal } from '@xterm/headless'
import { SerializeAddon } from '@xterm/addon-serialize'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider'
import {
activateOrcaTerminalUnicodeProvider,
setTerminalEastAsianAmbiguousWidthMode
} from '../../shared/terminal-unicode-provider'
import {
readSavedCursorRegister,
serializeWithAbsoluteCursor
} from '../../shared/terminal-serialize-absolute-cursor'
import { advancePartialEscapeTail } from '../../shared/terminal-partial-escape-tail'
import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes'
import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges'
import type {
HeadlessEmulatorOptions,
HeadlessEmulatorWriteOptions
} from './headless-emulator-options'
import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences'
import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror'
import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner'
Expand All @@ -18,24 +25,13 @@ import {
installTerminalViewAttributeResponder,
type TerminalViewAttributeResponder
} from './terminal-view-attribute-responder'
import type { TerminalSnapshot, TerminalModes } from './types'
import type { TerminalModes, TerminalSnapshot } from './types'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'

export type HeadlessEmulatorOptions = {
cols: number
rows: number
scrollback?: number
/** Query reply sink (terminal-query-authority.md); only `forwardQueryReplies` writes emit here. The daemon Session must never pass this. */
onQueryReply?: (reply: string) => void
pathFlavor?: 'posix' | 'win32'
remotePosixFileUriAuthority?: boolean
wslDistro?: string
}

export type HeadlessEmulatorWriteOptions = {
/** Reply ownership for this exact chunk; default false so seed/hydration/snapshot writes never forward (main-side replay guard; twin of renderer replay-guard.ts). */
forwardQueryReplies?: boolean
}
export type {
HeadlessEmulatorOptions,
HeadlessEmulatorWriteOptions
} from './headless-emulator-options'

type TerminalWithSynchronousWrite = Terminal & {
_core?: {
Expand Down Expand Up @@ -89,7 +85,10 @@ export class HeadlessEmulator {
this.serializer = new SerializeAddon()
this.terminal.loadAddon(this.serializer)

// Why Unicode 11: must match the renderer's char-width measurement, else emoji rows mismeasure and the mirror accumulates cell-shifted tears.
// Why: Unicode 11 + Orca EAW must match the renderer or SSH mirrors tear (#9958).
if (opts.eastAsianAmbiguousWidth !== undefined) {
setTerminalEastAsianAmbiguousWidthMode(opts.eastAsianAmbiguousWidth)
}
this.terminal.loadAddon(new Unicode11Addon())
activateOrcaTerminalUnicodeProvider(this.terminal)

Expand Down
6 changes: 6 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
import { callRuntimeEnvironment } from './ipc/runtime-environment-transport-routing'
import { resolveEnvironment } from '../shared/runtime-environment-store'
import { getPreferredPairingOffer } from '../shared/runtime-environments'
import { setTerminalEastAsianAmbiguousWidthMode } from '../shared/terminal-unicode-provider'
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
import {
recordRuntimeRpcStartFailure,
Expand Down Expand Up @@ -2001,11 +2002,16 @@ void app.whenReady().then(async () => {
logStartupMilestone('store-loaded')
// Why: apply initial fallback WSL distro from store settings for global git/CLI calls.
setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null)
// Why: headless emulators measure widths in this process; keep EAW mode aligned with Settings (#9958).
setTerminalEastAsianAmbiguousWidthMode(store.getSettings().terminalEastAsianAmbiguousWidth)
store.onSettingsChanged((updates, settings) => {
if ('terminalWindowsWslDistro' in updates) {
// Why: synchronize fallback WSL distro updates to runner.
setDefaultWslDistroOverride(settings.terminalWindowsWslDistro ?? null)
}
if ('terminalEastAsianAmbiguousWidth' in updates) {
setTerminalEastAsianAmbiguousWidthMode(settings.terminalEastAsianAmbiguousWidth)
}
if ('showMenuBarIcon' in updates) {
// Why: Store is the mutation authority for all settings writes, so every macOS toggle updates the native item live.
syncMacMenuBarIcon(settings.showMenuBarIcon !== false)
Expand Down
2 changes: 2 additions & 0 deletions src/main/runtime/orca-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10449,6 +10449,8 @@ export class OrcaRuntimeService {
wslDistro: this.ptysById.get(ptyId)?.connectionId
? undefined
: (this.wslDistroByPtyId.get(ptyId) ?? this.ptysById.get(ptyId)?.wslDistro ?? undefined),
// Why: mirror renderer East Asian Ambiguous width so SSH/headless buffers do not tear (#9958).
eastAsianAmbiguousWidth: this.store?.getSettings?.()?.terminalEastAsianAmbiguousWidth,
// Why: replies take the provider input path (same entry as pty:write —
// daemon shell-ready gating and the SSH relay write apply unchanged),
// NOT writePtyInput, so renderer interactive-output metering never
Expand Down
Loading