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 11579955926c..8b775da24371 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'
import { TERMINAL_WHEEL_SCROLL_JS } from './terminal-webview-wheel-scroll-injected'
const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = {
@@ -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
@@ -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.
@@ -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,
@@ -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);
}
@@ -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.
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..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'
@@ -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', () => {
diff --git a/src/main/daemon/headless-emulator-options.ts b/src/main/daemon/headless-emulator-options.ts
new file mode 100644
index 000000000000..aa49262525a9
--- /dev/null
+++ b/src/main/daemon/headless-emulator-options.ts
@@ -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
+}
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 ca8bb6c331f3..2cc2c5e0585d 100644
--- a/src/main/daemon/headless-emulator.ts
+++ b/src/main/daemon/headless-emulator.ts
@@ -2,7 +2,10 @@ 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
@@ -10,6 +13,10 @@ import {
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'
@@ -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?: {
@@ -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)
diff --git a/src/main/index.ts b/src/main/index.ts
index 5dbd92065f0b..6c8ad43096c0 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -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,
@@ -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)
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index 4770f89e234d..c1d023fbd405 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -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
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..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'
@@ -130,7 +134,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,
@@ -144,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
@@ -173,7 +183,11 @@ export function TerminalSettingsPreview({
}
const weights = resolveTerminalFontWeights(settings.terminalFontWeight)
terminal.options.fontSize = settings.terminalFontSize
- terminal.options.fontFamily = buildFontFamily(effectiveFontFamily)
+ // 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'
+ })
terminal.options.fontWeight = weights.fontWeight
terminal.options.fontWeightBold = weights.fontWeightBold
terminal.options.lineHeight = terminalLineHeight
@@ -185,6 +199,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..058f2e832a1d 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,
@@ -150,6 +151,12 @@ export function applyTerminalAppearance(
settings.terminalLigatures,
settings.terminalFontFamily
)
+ // Why: process-wide unicode mode â set once before the pane loop, not per pane (#10078).
+ const eastAsianAmbiguousWide = settings.terminalEastAsianAmbiguousWidth === 'wide'
+ setTerminalEastAsianAmbiguousWidthMode(settings.terminalEastAsianAmbiguousWidth)
+ const terminalFontFamily = buildFontFamily(settings.terminalFontFamily, {
+ eastAsianAmbiguousWide
+ })
for (const pane of manager.getPanes()) {
// Why value-gated: writing options.theme rebuilds the palette, discarding TUI OSC 4/10/11/12 mutations; skip on no-op change.
@@ -175,7 +182,7 @@ 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)
+ pane.terminal.options.fontFamily = terminalFontFamily
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 92f877819dcf..fb73a88a66a4 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
@@ -1423,7 +1423,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 dd6f41e43017..3c41e969bce7 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 541444f6d23c..30097081a095 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..5d3942c66fc0
--- /dev/null
+++ b/src/shared/east-asian-ambiguous-width.test.ts
@@ -0,0 +1,50 @@
+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
+ })
+
+ // Why: Nerd Font / Powerline / OMP icons live in BMP PUA and must stay single-cell.
+ it('does not treat Private Use Area as ambiguous', () => {
+ expect(isEastAsianAmbiguous(0xe0a0)).toBe(false) // Powerline branch
+ expect(isEastAsianAmbiguous(0xe0b0)).toBe(false) // Powerline separator
+ expect(isEastAsianAmbiguous(0xf0e7)).toBe(false) // typical Nerd Font codepoint
+ expect(isEastAsianAmbiguous(0xe000)).toBe(false)
+ expect(isEastAsianAmbiguous(0xf8ff)).toBe(false)
+ })
+})
+
+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)
+ })
+
+ it('keeps Nerd Font PUA single-cell even in wide mode', () => {
+ expect(resolveEastAsianAmbiguousCellWidth(0xe0b0, 1, 'wide')).toBe(1)
+ expect(resolveEastAsianAmbiguousCellWidth(0xf0e7, 1, 'wide')).toBe(1)
+ })
+})
diff --git a/src/shared/east-asian-ambiguous-width.ts b/src/shared/east-asian-ambiguous-width.ts
new file mode 100644
index 000000000000..31cda527755e
--- /dev/null
+++ b/src/shared/east-asian-ambiguous-width.ts
@@ -0,0 +1,225 @@
+/**
+ * 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.
+ *
+ * BMP Private Use Area (U+E000âU+F8FF) is omitted on purpose: Unicode marks it
+ * Ambiguous, but Nerd Fonts / Powerline / OMP place single-cell prompt icons
+ * there. Widening that range would break status bars for the same users this
+ * opt-in is meant to help.
+ */
+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],
+ // Why: Unicode classifies U+E000âU+F8FF (BMP Private Use Area) as EAW=A, but
+ // Nerd Fonts / Powerline / OMP map single-cell prompt icons into that range.
+ // Widening them in wide mode doubles every status-bar glyph and breaks prompts
+ // for the same CJK-locale users this opt-in targets (Greptile on #10078).
+ // Intentionally omit PUA â match practical terminals that keep Nerd Fonts at
+ // width 1 even when ambiguous mode is wide.
+ [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..ed7ce4c97c57 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
}
@@ -25,30 +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
+// 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)
- public constructor(private readonly baseProvider: IUnicodeVersionProvider) {}
+ if (codepoint === ZERO_WIDTH_JOINER && precedingWidth > 0) {
+ return createProperties(ZERO_WIDTH_JOINER, precedingWidth, true)
+ }
- public wcwidth(codepoint: number): 0 | 1 | 2 {
- return this.baseProvider.wcwidth(codepoint)
- }
-
- public charProperties(codepoint: number, preceding: number): number {
- const precedingWidth = extractWidth(preceding)
- const precedingKind = extractCharKind(preceding)
+ 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)
+ }
+ }
- if (codepoint === ZERO_WIDTH_JOINER && precedingWidth > 0) {
- return createProperties(ZERO_WIDTH_JOINER, 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
}
-
- 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)
- }
-
- return this.baseProvider.charProperties(codepoint, preceding)
}
}
@@ -65,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
}
diff --git a/src/shared/types.ts b/src/shared/types.ts
index cbbfa0d8776b..2694a9434236 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -2560,6 +2560,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'
@@ -2710,6 +2713,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