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