From 62ea8aec5744c7f96cf79424287b84c787d601b3 Mon Sep 17 00:00:00 2001
From: Wooseong Kim
Date: Thu, 23 Jul 2026 12:55:40 +0900
Subject: [PATCH 1/4] feat(terminal): opt-in wide East Asian Ambiguous
character width
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add terminalEastAsianAmbiguousWidth (narrow|wide, default narrow) so CJK
locales can match conhost double-width ambiguous glyphs (① ■ ★ →) without
changing the modern CLI-aligned default. Wide mode also inserts CJK UI
fonts ahead of Consolas so full-size glyphs fill the two-cell slot.
Closes #9958
---
.../TerminalAdvancedTypographyControls.tsx | 67 +++++-
.../settings/TerminalSettingsPreview.tsx | 9 +-
.../src/components/settings/setting-labels.ts | 1 +
.../settings/terminal-typography-search.ts | 27 +++
.../layout-serialization.test.ts | 8 +
.../terminal-pane/layout-serialization.ts | 33 ++-
.../terminal-pane/terminal-appearance.ts | 9 +-
.../use-terminal-pane-lifecycle.ts | 4 +-
.../src/lib/pane-manager/pane-lifecycle.ts | 11 +-
src/shared/constants.ts | 2 +
src/shared/east-asian-ambiguous-width.test.ts | 36 +++
src/shared/east-asian-ambiguous-width.ts | 215 ++++++++++++++++++
src/shared/terminal-unicode-provider.test.ts | 73 ++++++
src/shared/terminal-unicode-provider.ts | 30 ++-
src/shared/types.ts | 8 +
15 files changed, 523 insertions(+), 10 deletions(-)
create mode 100644 src/shared/east-asian-ambiguous-width.test.ts
create mode 100644 src/shared/east-asian-ambiguous-width.ts
create mode 100644 src/shared/terminal-unicode-provider.test.ts
diff --git a/src/renderer/src/components/settings/TerminalAdvancedTypographyControls.tsx b/src/renderer/src/components/settings/TerminalAdvancedTypographyControls.tsx
index 5acfee0d1906..4cb7962dc749 100644
--- a/src/renderer/src/components/settings/TerminalAdvancedTypographyControls.tsx
+++ b/src/renderer/src/components/settings/TerminalAdvancedTypographyControls.tsx
@@ -1,4 +1,4 @@
-import type { GlobalSettings } from '../../../../shared/types'
+import type { GlobalSettings, TerminalEastAsianAmbiguousWidth } from '../../../../shared/types'
import {
DEFAULT_TERMINAL_FONT_WEIGHT,
TERMINAL_FONT_WEIGHT_MAX,
@@ -183,6 +183,71 @@ export function TerminalAdvancedTypographyControls({
.
+
+
+
+ updateSettings({
+ terminalEastAsianAmbiguousWidth: option
+ })
+ }
+ options={[
+ {
+ value: 'narrow' as const,
+ label: translate(
+ 'auto.components.settings.TerminalAppearanceSection.eastAsianNarrow',
+ 'Narrow'
+ )
+ },
+ {
+ value: 'wide' as const,
+ label: translate(
+ 'auto.components.settings.TerminalAppearanceSection.eastAsianWide',
+ 'Wide'
+ )
+ }
+ ]}
+ />
+ }
+ />
+
)
}
diff --git a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx
index d9dee715dd74..0ed4d7564b31 100644
--- a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx
+++ b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx
@@ -130,7 +130,9 @@ export function TerminalSettingsPreview({
cursorStyle: settings.terminalCursorStyle,
cursorBlink: settings.terminalCursorBlink,
fontSize: settings.terminalFontSize,
- fontFamily: buildFontFamily(effectiveFontFamily),
+ fontFamily: buildFontFamily(effectiveFontFamily, {
+ eastAsianAmbiguousWide: settings.terminalEastAsianAmbiguousWidth === 'wide'
+ }),
fontWeight: weights.fontWeight,
fontWeightBold: weights.fontWeightBold,
lineHeight: terminalLineHeight,
@@ -173,7 +175,9 @@ export function TerminalSettingsPreview({
}
const weights = resolveTerminalFontWeights(settings.terminalFontWeight)
terminal.options.fontSize = settings.terminalFontSize
- terminal.options.fontFamily = buildFontFamily(effectiveFontFamily)
+ terminal.options.fontFamily = buildFontFamily(effectiveFontFamily, {
+ eastAsianAmbiguousWide: settings.terminalEastAsianAmbiguousWidth === 'wide'
+ })
terminal.options.fontWeight = weights.fontWeight
terminal.options.fontWeightBold = weights.fontWeightBold
terminal.options.lineHeight = terminalLineHeight
@@ -185,6 +189,7 @@ export function TerminalSettingsPreview({
settings.terminalFontSize,
effectiveFontFamily,
settings.terminalFontWeight,
+ settings.terminalEastAsianAmbiguousWidth,
terminalLineHeight,
settings.terminalCursorStyle,
settings.terminalCursorBlink
diff --git a/src/renderer/src/components/settings/setting-labels.ts b/src/renderer/src/components/settings/setting-labels.ts
index 95eb3a26c7c2..5c72d70eb743 100644
--- a/src/renderer/src/components/settings/setting-labels.ts
+++ b/src/renderer/src/components/settings/setting-labels.ts
@@ -3,6 +3,7 @@ import type { GlobalSettings } from '../../../../shared/types'
export const SETTING_LABELS: Partial> = {
terminalFontSize: 'Font Size',
terminalFontFamily: 'Font Family',
+ terminalEastAsianAmbiguousWidth: 'Ambiguous Character Width',
editorFontFamily: 'Editor Font Family',
terminalFontWeight: 'Font Weight',
terminalLineHeight: 'Line Height',
diff --git a/src/renderer/src/components/settings/terminal-typography-search.ts b/src/renderer/src/components/settings/terminal-typography-search.ts
index 02fe124d25ff..a45590431412 100644
--- a/src/renderer/src/components/settings/terminal-typography-search.ts
+++ b/src/renderer/src/components/settings/terminal-typography-search.ts
@@ -97,6 +97,33 @@ const getTerminalTypographySearchEntryCatalog = createLocalizedCatalog(() => [
'font features'
)
]
+ },
+ {
+ title: translate(
+ 'auto.components.settings.terminal.search.eastAsianAmbiguous',
+ 'Ambiguous Character Width'
+ ),
+ description: translate(
+ 'auto.components.settings.terminal.search.eastAsianAmbiguousDesc',
+ 'Treat East Asian Ambiguous characters (① ■ ★ →) as single- or double-width cells. Narrow matches modern CLIs; Wide matches conhost/CJK locales.'
+ ),
+ keywords: [
+ ...translateSearchKeyword('auto.components.settings.terminal.search.f66a7cf715', 'terminal'),
+ ...translateSearchKeyword(
+ 'auto.components.settings.terminal.search.103cdb862f',
+ 'typography'
+ ),
+ ...translateSearchKeyword('auto.components.settings.terminal.search.cjk', 'cjk'),
+ ...translateSearchKeyword('auto.components.settings.terminal.search.eastAsian', 'east asian'),
+ ...translateSearchKeyword('auto.components.settings.terminal.search.ambiguous', 'ambiguous'),
+ ...translateSearchKeyword(
+ 'auto.components.settings.terminal.search.doubleWidth',
+ 'double width'
+ ),
+ ...translateSearchKeyword('auto.components.settings.terminal.search.wide', 'wide'),
+ ...translateSearchKeyword('auto.components.settings.terminal.search.narrow', 'narrow'),
+ ...translateSearchKeyword('auto.components.settings.terminal.search.unicode', 'unicode')
+ ]
}
])
diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts
index 7977196979e5..da0a11e90aa4 100644
--- a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts
+++ b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts
@@ -123,6 +123,14 @@ describe('buildFontFamily', () => {
'"Orca Nerd Font Symbols", "SF Mono", "Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", "MesloLGS Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", monospace'
)
})
+
+ it('inserts CJK UI fonts ahead of Consolas when wide ambiguous mode is on', () => {
+ const result = buildFontFamily('Cascadia Mono', { eastAsianAmbiguousWide: true })
+ expect(result).toContain('"Malgun Gothic"')
+ expect(result).toContain('"Microsoft YaHei"')
+ expect(result.indexOf('"Malgun Gothic"')).toBeLessThan(result.indexOf('"Consolas"'))
+ expect(result.indexOf('"Cascadia Mono"')).toBeLessThan(result.indexOf('"Malgun Gothic"'))
+ })
})
// ---------------------------------------------------------------------------
diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts
index add3280e6587..4d562161e881 100644
--- a/src/renderer/src/components/terminal-pane/layout-serialization.ts
+++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts
@@ -79,16 +79,45 @@ const FALLBACK_FONTS = [
'monospace' // ultimate generic fallback
] as const
-export function buildFontFamily(fontFamily: string): string {
+// Why: wide ambiguous mode allocates two cells; CJK UI fonts supply full-em
+// glyphs so ①/★ are legible instead of Consolas' tiny ink (#9958).
+const CJK_AMBIGUOUS_FALLBACK_FONTS = [
+ 'Malgun Gothic', // Windows Korean
+ 'Microsoft YaHei', // Windows Chinese
+ 'Yu Gothic', // Windows Japanese
+ 'Meiryo', // Windows Japanese (older)
+ 'Apple SD Gothic Neo', // macOS Korean
+ 'PingFang SC', // macOS Chinese
+ 'Hiragino Sans', // macOS Japanese
+ 'Noto Sans CJK KR', // Linux Korean
+ 'Noto Sans CJK SC', // Linux Chinese
+ 'Noto Sans CJK JP' // Linux Japanese
+] as const
+
+export type BuildFontFamilyOptions = {
+ /** When true, insert CJK UI fonts ahead of Consolas for full-size ambiguous glyphs. */
+ eastAsianAmbiguousWide?: boolean
+}
+
+export function buildFontFamily(fontFamily: string, options?: BuildFontFamilyOptions): string {
const trimmed = fontFamily.trim()
const parts = trimmed ? [`"${trimmed}"`] : []
const lowerParts = parts.map((p) => p.toLowerCase())
+ const fallbacks: string[] = [...FALLBACK_FONTS]
+ // Why: place CJK before Consolas so Cascadia/SF Mono still win for Latin, but
+ // missing ambiguous glyphs skip Consolas' narrow ink (#9958).
+ if (options?.eastAsianAmbiguousWide) {
+ const consolasIndex = fallbacks.indexOf('Consolas')
+ const insertAt = consolasIndex >= 0 ? consolasIndex : fallbacks.length
+ fallbacks.splice(insertAt, 0, ...CJK_AMBIGUOUS_FALLBACK_FONTS)
+ }
// Append each fallback unless already present (case-insensitive) to avoid duplicates.
- for (const fallback of FALLBACK_FONTS) {
+ for (const fallback of fallbacks) {
const lower = fallback.toLowerCase()
if (!lowerParts.some((p) => p.includes(lower))) {
// Generic keywords like "monospace" are unquoted; named fonts are quoted.
parts.push(fallback === 'monospace' ? fallback : `"${fallback}"`)
+ lowerParts.push(lower)
}
}
return parts.join(', ')
diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts
index c1ff2c24f1a9..44bf02215e85 100644
--- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts
@@ -9,6 +9,7 @@ import {
resolveEffectiveTerminalAppearance
} from '@/lib/terminal-theme'
import { buildFontFamily } from './layout-serialization'
+import { setTerminalEastAsianAmbiguousWidthMode } from '../../../shared/terminal-unicode-provider'
import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops'
import {
normalizeTerminalFastScrollSensitivity,
@@ -175,7 +176,13 @@ export function applyTerminalAppearance(
pane.terminal.options.cursorBlink = settings.terminalCursorBlink
const paneSize = paneFontSizes.get(pane.id)
pane.terminal.options.fontSize = paneSize ?? settings.terminalFontSize
- pane.terminal.options.fontFamily = buildFontFamily(settings.terminalFontFamily)
+ // Why: keep the shared unicode provider and CJK font stack in lockstep with
+ // the opt-in wide ambiguous setting (#9958).
+ const eastAsianAmbiguousWide = settings.terminalEastAsianAmbiguousWidth === 'wide'
+ setTerminalEastAsianAmbiguousWidthMode(settings.terminalEastAsianAmbiguousWidth)
+ pane.terminal.options.fontFamily = buildFontFamily(settings.terminalFontFamily, {
+ eastAsianAmbiguousWide
+ })
pane.terminal.options.fontWeight = terminalFontWeights.fontWeight
pane.terminal.options.fontWeightBold = terminalFontWeights.fontWeightBold
pane.terminal.options.scrollSensitivity = normalizeTerminalScrollSensitivity(
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
index 48ced74b061b..4011267177fb 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
@@ -1318,7 +1318,9 @@ export function useTerminalPaneLifecycle({
...windowsPtyCompatibilityOptions,
...keyboardProtocolOptions,
fontSize: currentSettings?.terminalFontSize ?? 14,
- fontFamily: buildFontFamily(currentSettings?.terminalFontFamily ?? ''),
+ fontFamily: buildFontFamily(currentSettings?.terminalFontFamily ?? '', {
+ eastAsianAmbiguousWide: currentSettings?.terminalEastAsianAmbiguousWidth === 'wide'
+ }),
fontWeight: terminalFontWeights.fontWeight,
fontWeightBold: terminalFontWeights.fontWeightBold,
scrollback: normalizeDesktopTerminalScrollbackRows(
diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts
index 91cffb1e6d8b..41b17822d288 100644
--- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts
+++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts
@@ -6,7 +6,11 @@ import {
} from './pane-fit-resize-observer'
import { clearPendingSplitScrollRestore } from './pane-split-scroll'
import { cancelDeferredScrollRestore } from './pane-scroll'
-import { activateOrcaTerminalUnicodeProvider } from '../../../../shared/terminal-unicode-provider'
+import {
+ activateOrcaTerminalUnicodeProvider,
+ setTerminalEastAsianAmbiguousWidthMode
+} from '../../../../shared/terminal-unicode-provider'
+import { useAppStore } from '@/store'
import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel'
import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent-dom-tracking'
import { installTerminalLinkifierHoverResetOnMouseLeave } from './terminal-linkifier-hover-reset-on-mouseleave'
@@ -74,6 +78,11 @@ export function openTerminal(pane: ManagedPaneInternal): void {
// (replayTerminalLayout → splitPane/createInitialPane → openTerminal,
// restoreScrollbackBuffers, handleReattachResult) run after openTerminal,
// so the activation must stay at this position.
+ // Why: wide ambiguous mode must be applied before the first write so restore
+ // and live streams bake the same cell widths (#9958).
+ setTerminalEastAsianAmbiguousWidthMode(
+ useAppStore.getState().settings?.terminalEastAsianAmbiguousWidth
+ )
activateOrcaTerminalUnicodeProvider(terminal)
// Why: any xterm character joiner makes every repaint scan the whole grid.
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index 7c11e607d96a..f33c6f03bf08 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -205,6 +205,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
terminalFontSize: 14,
terminalFontFamily: defaultTerminalFontFamily(),
terminalFontWeight: DEFAULT_TERMINAL_FONT_WEIGHT,
+ // Why narrow: modern CLIs (unicode-width / string-width) treat ambiguous as 1; wide is an opt-in CJK conhost escape hatch (#9958).
+ terminalEastAsianAmbiguousWidth: 'narrow',
terminalLineHeight: 1,
terminalScrollSensitivity: 1.15,
terminalFastScrollSensitivity: 5,
diff --git a/src/shared/east-asian-ambiguous-width.test.ts b/src/shared/east-asian-ambiguous-width.test.ts
new file mode 100644
index 000000000000..aea6c4517367
--- /dev/null
+++ b/src/shared/east-asian-ambiguous-width.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest'
+import {
+ isEastAsianAmbiguous,
+ resolveEastAsianAmbiguousCellWidth
+} from './east-asian-ambiguous-width'
+
+describe('isEastAsianAmbiguous', () => {
+ it('classifies the Windows CJK ambiguous glyphs from #9958', () => {
+ expect(isEastAsianAmbiguous(0x2460)).toBe(true) // ①
+ expect(isEastAsianAmbiguous(0x25a0)).toBe(true) // ■
+ expect(isEastAsianAmbiguous(0x25cf)).toBe(true) // ●
+ expect(isEastAsianAmbiguous(0x2605)).toBe(true) // ★
+ expect(isEastAsianAmbiguous(0x2192)).toBe(true) // →
+ })
+
+ it('keeps true wide CJK and ASCII as non-ambiguous', () => {
+ expect(isEastAsianAmbiguous(0x6f22)).toBe(false) // 漢
+ expect(isEastAsianAmbiguous(0xd55c)).toBe(false) // 한
+ expect(isEastAsianAmbiguous(0x0041)).toBe(false) // A
+ expect(isEastAsianAmbiguous(0x0020)).toBe(false) // space
+ })
+})
+
+describe('resolveEastAsianAmbiguousCellWidth', () => {
+ it('leaves narrow mode unchanged', () => {
+ expect(resolveEastAsianAmbiguousCellWidth(0x2460, 1, 'narrow')).toBe(1)
+ expect(resolveEastAsianAmbiguousCellWidth(0x6f22, 2, 'narrow')).toBe(2)
+ })
+
+ it('widens only ambiguous base-width-1 cells when wide', () => {
+ expect(resolveEastAsianAmbiguousCellWidth(0x2460, 1, 'wide')).toBe(2)
+ expect(resolveEastAsianAmbiguousCellWidth(0x0041, 1, 'wide')).toBe(1)
+ expect(resolveEastAsianAmbiguousCellWidth(0x6f22, 2, 'wide')).toBe(2)
+ expect(resolveEastAsianAmbiguousCellWidth(0x0300, 0, 'wide')).toBe(0)
+ })
+})
diff --git a/src/shared/east-asian-ambiguous-width.ts b/src/shared/east-asian-ambiguous-width.ts
new file mode 100644
index 000000000000..99704930cce1
--- /dev/null
+++ b/src/shared/east-asian-ambiguous-width.ts
@@ -0,0 +1,215 @@
+/**
+ * East Asian Width = Ambiguous (A) code points from Unicode EAW.
+ * Used only when the user opts into double-width ambiguous cells (#9958).
+ * Compact inclusive [lo, hi] ranges, sorted for binary search.
+ */
+const AMBIGUOUS_RANGES: readonly (readonly [number, number])[] = [
+ [0x00a1, 0x00a1],
+ [0x00a4, 0x00a4],
+ [0x00a7, 0x00a8],
+ [0x00aa, 0x00aa],
+ [0x00ad, 0x00ae],
+ [0x00b0, 0x00b4],
+ [0x00b6, 0x00ba],
+ [0x00bc, 0x00bf],
+ [0x00c6, 0x00c6],
+ [0x00d0, 0x00d0],
+ [0x00d7, 0x00d8],
+ [0x00de, 0x00e1],
+ [0x00e6, 0x00e6],
+ [0x00e8, 0x00ea],
+ [0x00ec, 0x00ed],
+ [0x00f0, 0x00f0],
+ [0x00f2, 0x00f3],
+ [0x00f7, 0x00fa],
+ [0x00fc, 0x00fc],
+ [0x00fe, 0x00fe],
+ [0x0101, 0x0101],
+ [0x0111, 0x0111],
+ [0x0113, 0x0113],
+ [0x011b, 0x011b],
+ [0x0126, 0x0127],
+ [0x012b, 0x012b],
+ [0x0131, 0x0133],
+ [0x0138, 0x0138],
+ [0x013f, 0x0142],
+ [0x0144, 0x0144],
+ [0x0148, 0x014b],
+ [0x014d, 0x014d],
+ [0x0152, 0x0153],
+ [0x0166, 0x0167],
+ [0x016b, 0x016b],
+ [0x01ce, 0x01ce],
+ [0x01d0, 0x01d0],
+ [0x01d2, 0x01d2],
+ [0x01d4, 0x01d4],
+ [0x01d6, 0x01d6],
+ [0x01d8, 0x01d8],
+ [0x01da, 0x01da],
+ [0x01dc, 0x01dc],
+ [0x0251, 0x0251],
+ [0x0261, 0x0261],
+ [0x02c4, 0x02c4],
+ [0x02c7, 0x02c7],
+ [0x02c9, 0x02cb],
+ [0x02cd, 0x02cd],
+ [0x02d0, 0x02d0],
+ [0x02d8, 0x02db],
+ [0x02dd, 0x02dd],
+ [0x02df, 0x02df],
+ [0x0300, 0x036f],
+ [0x0391, 0x03a1],
+ [0x03a3, 0x03a9],
+ [0x03b1, 0x03c1],
+ [0x03c3, 0x03c9],
+ [0x0401, 0x0401],
+ [0x0410, 0x044f],
+ [0x0451, 0x0451],
+ [0x2010, 0x2010],
+ [0x2013, 0x2016],
+ [0x2018, 0x2019],
+ [0x201c, 0x201d],
+ [0x2020, 0x2022],
+ [0x2024, 0x2027],
+ [0x2030, 0x2030],
+ [0x2032, 0x2033],
+ [0x2035, 0x2035],
+ [0x203b, 0x203b],
+ [0x203e, 0x203e],
+ [0x2074, 0x2074],
+ [0x207f, 0x207f],
+ [0x2081, 0x2084],
+ [0x20ac, 0x20ac],
+ [0x2103, 0x2103],
+ [0x2105, 0x2105],
+ [0x2109, 0x2109],
+ [0x2113, 0x2113],
+ [0x2116, 0x2116],
+ [0x2121, 0x2122],
+ [0x2126, 0x2126],
+ [0x212b, 0x212b],
+ [0x2153, 0x2154],
+ [0x215b, 0x215e],
+ [0x2160, 0x216b],
+ [0x2170, 0x2179],
+ [0x2189, 0x2189],
+ [0x2190, 0x2199],
+ [0x21b8, 0x21b9],
+ [0x21d2, 0x21d2],
+ [0x21d4, 0x21d4],
+ [0x21e7, 0x21e7],
+ [0x2200, 0x2200],
+ [0x2202, 0x2203],
+ [0x2207, 0x2208],
+ [0x220b, 0x220b],
+ [0x220f, 0x220f],
+ [0x2211, 0x2211],
+ [0x2215, 0x2215],
+ [0x221a, 0x221a],
+ [0x221d, 0x2220],
+ [0x2223, 0x2223],
+ [0x2225, 0x2225],
+ [0x2227, 0x222c],
+ [0x222e, 0x222e],
+ [0x2234, 0x2237],
+ [0x223c, 0x223d],
+ [0x2248, 0x2248],
+ [0x224c, 0x224c],
+ [0x2252, 0x2252],
+ [0x2260, 0x2261],
+ [0x2264, 0x2267],
+ [0x226a, 0x226b],
+ [0x226e, 0x226f],
+ [0x2282, 0x2283],
+ [0x2286, 0x2287],
+ [0x2295, 0x2295],
+ [0x2299, 0x2299],
+ [0x22a5, 0x22a5],
+ [0x22bf, 0x22bf],
+ [0x2312, 0x2312],
+ [0x2460, 0x24e9],
+ [0x24eb, 0x254b],
+ [0x2550, 0x2573],
+ [0x2580, 0x258f],
+ [0x2592, 0x2595],
+ [0x25a0, 0x25a1],
+ [0x25a3, 0x25a9],
+ [0x25b2, 0x25b3],
+ [0x25b6, 0x25b7],
+ [0x25bc, 0x25bd],
+ [0x25c0, 0x25c1],
+ [0x25c6, 0x25c8],
+ [0x25cb, 0x25cb],
+ [0x25ce, 0x25d1],
+ [0x25e2, 0x25e5],
+ [0x25ef, 0x25ef],
+ [0x2605, 0x2606],
+ [0x2609, 0x2609],
+ [0x260e, 0x260f],
+ [0x261c, 0x261c],
+ [0x261e, 0x261e],
+ [0x2640, 0x2640],
+ [0x2642, 0x2642],
+ [0x2660, 0x2661],
+ [0x2663, 0x2665],
+ [0x2667, 0x266a],
+ [0x266c, 0x266d],
+ [0x266f, 0x266f],
+ [0x269e, 0x269f],
+ [0x26bf, 0x26bf],
+ [0x26c6, 0x26cd],
+ [0x26cf, 0x26d3],
+ [0x26d5, 0x26e1],
+ [0x26e3, 0x26e3],
+ [0x26e8, 0x26e9],
+ [0x26eb, 0x26f1],
+ [0x26f4, 0x26f4],
+ [0x26f6, 0x26f9],
+ [0x26fb, 0x26fc],
+ [0x26fe, 0x26ff],
+ [0x273d, 0x273d],
+ [0x2776, 0x277f],
+ [0x2b56, 0x2b59],
+ [0x3248, 0x324f],
+ [0xe000, 0xf8ff],
+ [0xfe00, 0xfe0f],
+ [0xfffd, 0xfffd],
+ [0x1f100, 0x1f10a],
+ [0x1f110, 0x1f12d],
+ [0x1f130, 0x1f169],
+ [0x1f170, 0x1f1ac]
+]
+
+/** True when Unicode East_Asian_Width(codepoint) is Ambiguous (A). */
+export function isEastAsianAmbiguous(codepoint: number): boolean {
+ if (codepoint < 0 || codepoint > 0x10ffff) {
+ return false
+ }
+ let lo = 0
+ let hi = AMBIGUOUS_RANGES.length - 1
+ while (lo <= hi) {
+ const mid = (lo + hi) >> 1
+ const range = AMBIGUOUS_RANGES[mid]!
+ if (codepoint < range[0]) {
+ hi = mid - 1
+ } else if (codepoint > range[1]) {
+ lo = mid + 1
+ } else {
+ return true
+ }
+ }
+ return false
+}
+
+export type TerminalEastAsianAmbiguousWidth = 'narrow' | 'wide'
+
+export function resolveEastAsianAmbiguousCellWidth(
+ codepoint: number,
+ baseWidth: 0 | 1 | 2,
+ mode: TerminalEastAsianAmbiguousWidth
+): 0 | 1 | 2 {
+ if (mode !== 'wide' || baseWidth !== 1) {
+ return baseWidth
+ }
+ return isEastAsianAmbiguous(codepoint) ? 2 : 1
+}
diff --git a/src/shared/terminal-unicode-provider.test.ts b/src/shared/terminal-unicode-provider.test.ts
new file mode 100644
index 000000000000..73dbb60eb13c
--- /dev/null
+++ b/src/shared/terminal-unicode-provider.test.ts
@@ -0,0 +1,73 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ activateOrcaTerminalUnicodeProvider,
+ setTerminalEastAsianAmbiguousWidthMode
+} from './terminal-unicode-provider'
+
+afterEach(() => {
+ setTerminalEastAsianAmbiguousWidthMode('narrow')
+})
+
+function makeUnicodeHarness(baseWcwidth: (cp: number) => 0 | 1 | 2) {
+ const baseProvider = {
+ version: '11',
+ wcwidth: baseWcwidth,
+ charProperties: (codepoint: number, _preceding: number) => {
+ const width = baseWcwidth(codepoint)
+ return ((codepoint & 0xffffff) << 3) | ((width & 3) << 1)
+ }
+ }
+ const providers: Record = {
+ '11': baseProvider
+ }
+ const registered: { version: string; wcwidth: (cp: number) => 0 | 1 | 2 }[] = []
+ const unicode = {
+ activeVersion: '11',
+ versions: ['11'] as string[],
+ register(provider: { version: string; wcwidth: (cp: number) => 0 | 1 | 2 }) {
+ registered.push(provider)
+ providers[provider.version] = provider as typeof baseProvider
+ this.versions.push(provider.version)
+ }
+ }
+ const terminal = {
+ unicode,
+ _core: { unicodeService: { _providers: providers } }
+ }
+ return { terminal, registered, providers }
+}
+
+describe('activateOrcaTerminalUnicodeProvider ambiguous width', () => {
+ it('widens East Asian Ambiguous cells only in wide mode', () => {
+ const baseWcwidth = (cp: number): 0 | 1 | 2 => {
+ if (cp === 0x6f22) {
+ return 2
+ }
+ if (cp === 0) {
+ return 0
+ }
+ return 1
+ }
+ const { terminal, registered } = makeUnicodeHarness(baseWcwidth)
+ activateOrcaTerminalUnicodeProvider(terminal)
+
+ const provider = registered[0]!
+ expect(provider.wcwidth(0x2460)).toBe(1)
+
+ setTerminalEastAsianAmbiguousWidthMode('wide')
+ expect(provider.wcwidth(0x2460)).toBe(2) // ①
+ expect(provider.wcwidth(0x2605)).toBe(2) // ★
+ expect(provider.wcwidth(0x0041)).toBe(1) // A
+ expect(provider.wcwidth(0x6f22)).toBe(2) // 漢 stays wide
+ })
+
+ it('is a no-op when already active', () => {
+ const { terminal, registered } = makeUnicodeHarness(() => 1)
+ activateOrcaTerminalUnicodeProvider(terminal)
+ const registerSpy = vi.fn()
+ terminal.unicode.register = registerSpy
+ activateOrcaTerminalUnicodeProvider(terminal)
+ expect(registerSpy).not.toHaveBeenCalled()
+ expect(registered).toHaveLength(1)
+ })
+})
diff --git a/src/shared/terminal-unicode-provider.ts b/src/shared/terminal-unicode-provider.ts
index ecd0fe7e7823..3d4e17467688 100644
--- a/src/shared/terminal-unicode-provider.ts
+++ b/src/shared/terminal-unicode-provider.ts
@@ -1,4 +1,8 @@
import type { IUnicodeHandling, IUnicodeVersionProvider } from '@xterm/xterm'
+import {
+ resolveEastAsianAmbiguousCellWidth,
+ type TerminalEastAsianAmbiguousWidth
+} from './east-asian-ambiguous-width'
type XtermTerminalWithUnicodeCore = {
unicode: IUnicodeHandling
@@ -13,6 +17,20 @@ const ORCA_UNICODE_VERSION = 'orca-11-zwj'
const UNICODE11_VERSION = '11'
const ZERO_WIDTH_JOINER = 0x200d
+// Why: settings can change after the provider is registered; read mode at
+// measurement time so new cells honor the toggle without re-registering.
+let eastAsianAmbiguousWidthMode: TerminalEastAsianAmbiguousWidth = 'narrow'
+
+export function getTerminalEastAsianAmbiguousWidthMode(): TerminalEastAsianAmbiguousWidth {
+ return eastAsianAmbiguousWidthMode
+}
+
+export function setTerminalEastAsianAmbiguousWidthMode(
+ mode: TerminalEastAsianAmbiguousWidth | null | undefined
+): void {
+ eastAsianAmbiguousWidthMode = mode === 'wide' ? 'wide' : 'narrow'
+}
+
function extractWidth(properties: number): 0 | 1 | 2 {
return ((properties >> 1) & 3) as 0 | 1 | 2
}
@@ -31,7 +49,8 @@ class OrcaUnicodeProvider implements IUnicodeVersionProvider {
public constructor(private readonly baseProvider: IUnicodeVersionProvider) {}
public wcwidth(codepoint: number): 0 | 1 | 2 {
- return this.baseProvider.wcwidth(codepoint)
+ const baseWidth = this.baseProvider.wcwidth(codepoint)
+ return resolveEastAsianAmbiguousCellWidth(codepoint, baseWidth, eastAsianAmbiguousWidthMode)
}
public charProperties(codepoint: number, preceding: number): number {
@@ -48,7 +67,14 @@ class OrcaUnicodeProvider implements IUnicodeVersionProvider {
return createProperties(codepoint, precedingWidth, true)
}
- return this.baseProvider.charProperties(codepoint, preceding)
+ const base = this.baseProvider.charProperties(codepoint, preceding)
+ const desiredWidth = this.wcwidth(codepoint)
+ // Why: charProperties packs width into the property word; keep it aligned
+ // with wcwidth when wide-mode expands East Asian Ambiguous cells.
+ if (desiredWidth !== extractWidth(base)) {
+ return createProperties(extractCharKind(base), desiredWidth, (base & 1) === 1)
+ }
+ return base
}
}
diff --git a/src/shared/types.ts b/src/shared/types.ts
index c1faa77ab4ce..50ae2412ba40 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -2539,6 +2539,9 @@ export type TaskViewPresetId = 'all' | 'issues' | 'review' | 'my-issues' | 'my-p
* - 'split-horizontal': split the initial terminal pane with a horizontal divider. */
export type SetupScriptLaunchMode = 'split-vertical' | 'split-horizontal' | 'new-tab'
+/** Opt-in East Asian Ambiguous cell width for the terminal (iTerm2-style escape hatch). */
+export type TerminalEastAsianAmbiguousWidth = 'narrow' | 'wide'
+
/** Direction used when the setup script launch mode is a split. */
export type SetupSplitDirection = 'vertical' | 'horizontal'
@@ -2689,6 +2692,11 @@ export type GlobalSettings = {
terminalFontSize: number
terminalFontFamily: string
terminalFontWeight: number
+ /**
+ * East Asian Ambiguous (① ■ ★ →) cell width. Default `narrow` matches modern
+ * CLI width tables; `wide` matches conhost/CJK locales (#9958).
+ */
+ terminalEastAsianAmbiguousWidth?: TerminalEastAsianAmbiguousWidth
terminalLineHeight: number
terminalScrollSensitivity: number
terminalFastScrollSensitivity: number
From bae2e4e42d7dba56bfa0c86db7d789d3e709eda5 Mon Sep 17 00:00:00 2001
From: Wooseong Kim
Date: Sun, 26 Jul 2026 12:18:23 +0900
Subject: [PATCH 2/4] fix(terminal): activate unicode provider in settings
preview
buildFontFamily only adjusts the CJK fallback stack; activate Orca's
unicode provider (and wide ambiguous mode) before writing the sample
buffer so the preview measures like live panes.
---
.../components/settings/TerminalSettingsPreview.tsx | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx
index 0ed4d7564b31..b186f1d2bfea 100644
--- a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx
+++ b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx
@@ -12,6 +12,10 @@ import { resolveTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-cor
import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts'
import { resolveTerminalLigaturesEnabled } from '../../../../shared/terminal-ligatures'
import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings'
+import {
+ activateOrcaTerminalUnicodeProvider,
+ setTerminalEastAsianAmbiguousWidthMode
+} from '../../../../shared/terminal-unicode-provider'
import { PREVIEW_BUFFER } from './terminal-preview-content'
import { SettingsSwitch } from './SettingsFormControls'
import type { GlobalSettings } from '../../../../shared/types'
@@ -146,6 +150,10 @@ export function TerminalSettingsPreview({
try {
terminal.open(container)
+ // Why: bake ambiguous widths with Orca's provider before the sample write
+ // so wide mode is not stuck on xterm's default narrow tables (#9958).
+ setTerminalEastAsianAmbiguousWidthMode(settings.terminalEastAsianAmbiguousWidth)
+ activateOrcaTerminalUnicodeProvider(terminal)
terminal.write(PREVIEW_BUFFER)
} catch (err) {
terminalRef.current = null
@@ -175,6 +183,8 @@ export function TerminalSettingsPreview({
}
const weights = resolveTerminalFontWeights(settings.terminalFontWeight)
terminal.options.fontSize = settings.terminalFontSize
+ // Why: keep shared measurement mode + CJK fallback stack in lockstep with live panes.
+ setTerminalEastAsianAmbiguousWidthMode(settings.terminalEastAsianAmbiguousWidth)
terminal.options.fontFamily = buildFontFamily(effectiveFontFamily, {
eastAsianAmbiguousWide: settings.terminalEastAsianAmbiguousWidth === 'wide'
})
From 1cb8121b2c195f8dcda8e309f05c8a6508452b38 Mon Sep 17 00:00:00 2001
From: Wooseong Kim
Date: Thu, 30 Jul 2026 17:45:59 +0900
Subject: [PATCH 3/4] feat(terminal): wire EAW wide mode through headless and
mobile
DoDAON / #9958 asked for all three activation sites. Desktop already set
mode + OrcaUnicodeProvider; this pass completes the twins:
- Headless: honor eastAsianAmbiguousWidth (runtime store + settings sync)
- Mobile: bundle Orca unicode into the WebView engine and activate after
Unicode11 (was bypassing Orca entirely); CJK font stack when wide
- Provider as plain object so the Chrome 74 engine floor parses cleanly
Evidence: headless-emulator-unicode-width + mobile engine/html tests
---
.../scripts/build-terminal-webview-engine.mjs | 10 +++
mobile/src/terminal/TerminalWebView.tsx | 14 +++-
.../src/terminal/terminal-webview-contract.ts | 2 +
.../terminal/terminal-webview-eaw-injected.ts | 38 ++++++++++
mobile/src/terminal/terminal-webview-html.ts | 13 +++-
.../src/terminal/terminal-webview-messages.ts | 2 +
.../terminal-webview-text-zoom.test.ts | 10 ++-
.../headless-emulator-unicode-width.test.ts | 30 +++++++-
src/main/daemon/headless-emulator.ts | 19 ++++-
src/main/index.ts | 6 ++
src/main/runtime/orca-runtime.ts | 2 +
src/shared/terminal-unicode-provider.ts | 76 +++++++++++--------
12 files changed, 177 insertions(+), 45 deletions(-)
create mode 100644 mobile/src/terminal/terminal-webview-eaw-injected.ts
diff --git a/mobile/scripts/build-terminal-webview-engine.mjs b/mobile/scripts/build-terminal-webview-engine.mjs
index ef6bf0dc1b75..79ce82d914b6 100644
--- a/mobile/scripts/build-terminal-webview-engine.mjs
+++ b/mobile/scripts/build-terminal-webview-engine.mjs
@@ -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
@@ -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'
diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx
index f7ab3af1a3c1..fa7737202a1e 100644
--- a/mobile/src/terminal/TerminalWebView.tsx
+++ b/mobile/src/terminal/TerminalWebView.tsx
@@ -25,6 +25,7 @@ export const TerminalWebView = forwardRef(function
style,
terminalTheme,
textScale = 1,
+ eastAsianAmbiguousWidth = 'narrow',
onWebReady,
onEngineError,
onSelectionMode,
@@ -280,7 +281,8 @@ export const TerminalWebView = forwardRef(function
oscLinks,
terminalTheme,
fontScale: textScale,
- preserveScroll
+ preserveScroll,
+ eastAsianAmbiguousWidth
})
},
resize(cols: number, rows: number) {
@@ -360,7 +362,15 @@ export const TerminalWebView = forwardRef(function
})
}
}),
- [armWebReadyWatchdog, postMessage, sendToWebView, terminalTheme, textScale, writeCoalescer]
+ [
+ armWebReadyWatchdog,
+ eastAsianAmbiguousWidth,
+ postMessage,
+ sendToWebView,
+ terminalTheme,
+ textScale,
+ writeCoalescer
+ ]
)
return (
diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts
index 63c155b7a23f..be40fd2b42a0 100644
--- a/mobile/src/terminal/terminal-webview-contract.ts
+++ b/mobile/src/terminal/terminal-webview-contract.ts
@@ -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
diff --git a/mobile/src/terminal/terminal-webview-eaw-injected.ts b/mobile/src/terminal/terminal-webview-eaw-injected.ts
new file mode 100644
index 000000000000..e3d5a4e9f781
--- /dev/null
+++ b/mobile/src/terminal/terminal-webview-eaw-injected.ts
@@ -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) {}
+ }
+ }
+`
diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts
index 767b2cc6b6c4..cadbdc9c4233 100644
--- a/mobile/src/terminal/terminal-webview-html.ts
+++ b/mobile/src/terminal/terminal-webview-html.ts
@@ -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'
const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = {
background: colors.terminalBg,
@@ -256,7 +257,8 @@ window.onerror = function(msg) {
// Why: iOS WebKit does not reliably resolve "SF Mono" by CSS family name and can
// 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}
+ var terminalFontFamily = buildTerminalFontFamily();
// 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
@@ -666,8 +668,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.
@@ -711,6 +715,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
var nextSurface = surfaceSwap.nextSurface;
applyTerminalTheme(nextTheme);
+ terminalFontFamily = buildTerminalFontFamily();
term = new Terminal({
cols: cols || 80,
rows: rows || 24,
@@ -736,7 +741,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);
}
@@ -916,7 +921,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.
diff --git a/mobile/src/terminal/terminal-webview-messages.ts b/mobile/src/terminal/terminal-webview-messages.ts
index c031363268c1..9419b94df67a 100644
--- a/mobile/src/terminal/terminal-webview-messages.ts
+++ b/mobile/src/terminal/terminal-webview-messages.ts
@@ -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 }
diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts
index 09aeb82d3f74..9e1ac73e8b97 100644
--- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts
+++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts
@@ -139,15 +139,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(terminalHtmlSource).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', () => {
diff --git a/src/main/daemon/headless-emulator-unicode-width.test.ts b/src/main/daemon/headless-emulator-unicode-width.test.ts
index f358ce79189d..b74889bd3db4 100644
--- a/src/main/daemon/headless-emulator-unicode-width.test.ts
+++ b/src/main/daemon/headless-emulator-unicode-width.test.ts
@@ -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),
@@ -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()
+ })
})
diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts
index 7cd36fe80f32..20e2850e535a 100644
--- a/src/main/daemon/headless-emulator.ts
+++ b/src/main/daemon/headless-emulator.ts
@@ -2,7 +2,11 @@ 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 type { TerminalEastAsianAmbiguousWidth } from '../../shared/east-asian-ambiguous-width'
import {
readSavedCursorRegister,
serializeWithAbsoluteCursor
@@ -30,6 +34,12 @@ export type HeadlessEmulatorOptions = {
pathFlavor?: 'posix' | 'win32'
remotePosixFileUriAuthority?: boolean
wslDistro?: string
+ /**
+ * East Asian Ambiguous cell width for this process's Orca unicode provider.
+ * Must match the renderer setting so headless mirrors do not tear under SSH (#9958).
+ * When omitted, the process-wide mode is left unchanged (default `narrow`).
+ */
+ eastAsianAmbiguousWidth?: TerminalEastAsianAmbiguousWidth
}
export type HeadlessEmulatorWriteOptions = {
@@ -89,7 +99,12 @@ 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 provider: must match the renderer's char-width measurement
+ // (including opt-in wide East Asian Ambiguous mode), else emoji/CJK rows mismeasure
+ // and the SSH/daemon mirror accumulates cell-shifted tears (#9958).
+ if (opts.eastAsianAmbiguousWidth !== undefined) {
+ setTerminalEastAsianAmbiguousWidthMode(opts.eastAsianAmbiguousWidth)
+ }
this.terminal.loadAddon(new Unicode11Addon())
activateOrcaTerminalUnicodeProvider(this.terminal)
diff --git a/src/main/index.ts b/src/main/index.ts
index 9f7e559cb880..84d935d40adf 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -56,6 +56,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 { resolveAdvertisedPairingEndpoint } from './runtime/pairing-endpoint'
import { ServeReadinessPublisher } from './server/serve-readiness'
@@ -1905,11 +1906,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)
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index dac1b9f0ffd6..64e9fc830840 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -9306,6 +9306,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
diff --git a/src/shared/terminal-unicode-provider.ts b/src/shared/terminal-unicode-provider.ts
index 3d4e17467688..ed7ce4c97c57 100644
--- a/src/shared/terminal-unicode-provider.ts
+++ b/src/shared/terminal-unicode-provider.ts
@@ -43,38 +43,50 @@ function createProperties(charKind: number, width: 0 | 1 | 2, shouldJoin: boolea
return ((charKind & 0xffffff) << 3) | ((width & 3) << 1) | (shouldJoin ? 1 : 0)
}
-class OrcaUnicodeProvider implements IUnicodeVersionProvider {
- public readonly version = ORCA_UNICODE_VERSION
-
- public constructor(private readonly baseProvider: IUnicodeVersionProvider) {}
-
- public wcwidth(codepoint: number): 0 | 1 | 2 {
- const baseWidth = this.baseProvider.wcwidth(codepoint)
- return resolveEastAsianAmbiguousCellWidth(codepoint, baseWidth, eastAsianAmbiguousWidthMode)
- }
-
- public charProperties(codepoint: number, preceding: number): number {
- const precedingWidth = extractWidth(preceding)
- const precedingKind = extractCharKind(preceding)
-
- if (codepoint === ZERO_WIDTH_JOINER && precedingWidth > 0) {
- return createProperties(ZERO_WIDTH_JOINER, precedingWidth, true)
- }
-
- if (precedingKind === ZERO_WIDTH_JOINER && precedingWidth > 0 && this.wcwidth(codepoint) > 0) {
- // Why: CLIs render ZWJ emoji as one visible glyph and budget them as one
- // wide cell pair; xterm Unicode11 otherwise advances for both emoji parts.
- return createProperties(codepoint, precedingWidth, true)
- }
-
- const base = this.baseProvider.charProperties(codepoint, preceding)
- const desiredWidth = this.wcwidth(codepoint)
- // Why: charProperties packs width into the property word; keep it aligned
- // with wcwidth when wide-mode expands East Asian Ambiguous cells.
- if (desiredWidth !== extractWidth(base)) {
- return createProperties(extractCharKind(base), desiredWidth, (base & 1) === 1)
+// Why: plain object provider (not a class with field decls) so the mobile WebView
+// engine esbuild (Chrome 74 / ES2019 floor) never emits class-field syntax (#9958).
+function createOrcaUnicodeProvider(baseProvider: IUnicodeVersionProvider): IUnicodeVersionProvider {
+ return {
+ version: ORCA_UNICODE_VERSION,
+ wcwidth(codepoint: number): 0 | 1 | 2 {
+ const baseWidth = baseProvider.wcwidth(codepoint)
+ return resolveEastAsianAmbiguousCellWidth(codepoint, baseWidth, eastAsianAmbiguousWidthMode)
+ },
+ charProperties(codepoint: number, preceding: number): number {
+ const precedingWidth = extractWidth(preceding)
+ const precedingKind = extractCharKind(preceding)
+
+ if (codepoint === ZERO_WIDTH_JOINER && precedingWidth > 0) {
+ return createProperties(ZERO_WIDTH_JOINER, precedingWidth, true)
+ }
+
+ if (precedingKind === ZERO_WIDTH_JOINER && precedingWidth > 0) {
+ const width = baseProvider.wcwidth(codepoint)
+ const resolved = resolveEastAsianAmbiguousCellWidth(
+ codepoint,
+ width,
+ eastAsianAmbiguousWidthMode
+ )
+ if (resolved > 0) {
+ // Why: CLIs render ZWJ emoji as one visible glyph and budget them as one
+ // wide cell pair; xterm Unicode11 otherwise advances for both emoji parts.
+ return createProperties(codepoint, precedingWidth, true)
+ }
+ }
+
+ const base = baseProvider.charProperties(codepoint, preceding)
+ const desiredWidth = resolveEastAsianAmbiguousCellWidth(
+ codepoint,
+ baseProvider.wcwidth(codepoint),
+ eastAsianAmbiguousWidthMode
+ )
+ // Why: charProperties packs width into the property word; keep it aligned
+ // with wcwidth when wide-mode expands East Asian Ambiguous cells.
+ if (desiredWidth !== extractWidth(base)) {
+ return createProperties(extractCharKind(base), desiredWidth, (base & 1) === 1)
+ }
+ return base
}
- return base
}
}
@@ -91,7 +103,7 @@ export function activateOrcaTerminalUnicodeProvider(terminal: XtermTerminalWithU
}
if (!unicode.versions.includes(ORCA_UNICODE_VERSION)) {
- unicode.register(new OrcaUnicodeProvider(baseProvider))
+ unicode.register(createOrcaUnicodeProvider(baseProvider))
}
unicode.activeVersion = ORCA_UNICODE_VERSION
}
From 26f03fc82913f5123d2576b513b11e619a34d7d0 Mon Sep 17 00:00:00 2001
From: Wooseong Kim
Date: Thu, 30 Jul 2026 17:47:15 +0900
Subject: [PATCH 4/4] test(mobile): keep EAW inject out of font-family extract
path
Place TERMINAL_EAW_JS after the default terminalFontFamily assignment so
vm-based font fallback tests still slice a valid isIOSWebView block.
---
mobile/src/terminal/terminal-webview-html.ts | 2 +-
mobile/src/terminal/terminal-webview-text-zoom.test.ts | 8 ++++++--
2 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts
index cadbdc9c4233..83a8f5eae43c 100644
--- a/mobile/src/terminal/terminal-webview-html.ts
+++ b/mobile/src/terminal/terminal-webview-html.ts
@@ -257,8 +257,8 @@ window.onerror = function(msg) {
// Why: iOS WebKit does not reliably resolve "SF Mono" by CSS family name and can
// 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}
- var terminalFontFamily = buildTerminalFontFamily();
// 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
diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts
index 9e1ac73e8b97..fdd6290bf30c 100644
--- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts
+++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts
@@ -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'
@@ -141,9 +145,9 @@ describe('TerminalWebView text zoom', () => {
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(terminalHtmlSource).toContain('window.OrcaTerminalUnicode.activate')
+ expect(terminalEawSource).toContain('window.Unicode11Addon.Unicode11Addon')
+ expect(terminalEawSource).toContain('window.OrcaTerminalUnicode.activate')
const open = terminalHtmlSource.indexOf('term.open(surface)')
const orca = terminalHtmlSource.indexOf('activateOrcaUnicodeOnTerm(term)')
const replay = terminalHtmlSource.indexOf('enqueueWrite(replayData)')