Skip to content

Commit e78b862

Browse files
Merge pull request #1626 from iodic/fix/utf8-pty-render
fix(pty): preserve UTF-8 locale for embedded terminals
2 parents 8c03a5d + 4a188ff commit e78b862

6 files changed

Lines changed: 561 additions & 25 deletions

File tree

src/main/services/ptyManager.ts

Lines changed: 153 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import os from 'os';
22
import fs from 'fs';
33
import path from 'path';
44
import crypto from 'crypto';
5+
import { StringDecoder } from 'string_decoder';
56
import type { IPty } from 'node-pty';
67
import { log } from '../lib/logger';
78
import { PROVIDERS, type ProviderDefinition } from '@shared/providers/registry';
89
import { parsePtyId } from '@shared/ptyId';
910
import { providerStatusCache } from './providerStatusCache';
1011
import { errorTracking } from '../errorTracking';
12+
import { LOCALE_ENV_VARS, DEFAULT_UTF8_LOCALE, isUtf8Locale } from '../utils/locale';
1113

1214
/**
1315
* Suppress EPIPE/EIO errors on a PTY's underlying socket.
@@ -103,6 +105,64 @@ type PtyRecord = {
103105
const ptys = new Map<string, PtyRecord>();
104106
const MIN_PTY_COLS = 2;
105107
const MIN_PTY_ROWS = 1;
108+
export function getLocaleEnv(sourceEnv: NodeJS.ProcessEnv = process.env): Record<string, string> {
109+
if (process.platform === 'win32') {
110+
const localeEnv: Record<string, string> = {};
111+
for (const key of LOCALE_ENV_VARS) {
112+
const value = sourceEnv[key];
113+
if (value && isUtf8Locale(value)) {
114+
localeEnv[key] = value;
115+
}
116+
}
117+
return localeEnv;
118+
}
119+
120+
// On non-Windows, preserve explicit UTF-8 locale choices and only fall back
121+
// to a minimal UTF-8 locale when no effective UTF-8 locale is available.
122+
const localeEnv: Record<string, string> = {};
123+
const lang = sourceEnv.LANG;
124+
const lcAll = sourceEnv.LC_ALL;
125+
const lcCtype = sourceEnv.LC_CTYPE;
126+
127+
if (lcAll && isUtf8Locale(lcAll)) {
128+
localeEnv.LC_ALL = lcAll;
129+
}
130+
if (lang && isUtf8Locale(lang)) {
131+
localeEnv.LANG = lang;
132+
}
133+
if (lcCtype && isUtf8Locale(lcCtype)) {
134+
localeEnv.LC_CTYPE = lcCtype;
135+
}
136+
137+
if (localeEnv.LC_ALL || localeEnv.LANG || localeEnv.LC_CTYPE) {
138+
return localeEnv;
139+
}
140+
141+
localeEnv.LANG = DEFAULT_UTF8_LOCALE;
142+
localeEnv.LC_CTYPE = DEFAULT_UTF8_LOCALE;
143+
return localeEnv;
144+
}
145+
146+
export function mergeEnvWithNormalizedLocale(
147+
...envs: Array<NodeJS.ProcessEnv | undefined>
148+
): Record<string, string> {
149+
const mergedEnv: NodeJS.ProcessEnv = {};
150+
151+
for (const env of envs) {
152+
if (!env) continue;
153+
Object.assign(mergedEnv, env);
154+
}
155+
156+
const localeEnv = getLocaleEnv(mergedEnv);
157+
for (const key of LOCALE_ENV_VARS) {
158+
delete mergedEnv[key];
159+
}
160+
161+
return {
162+
...mergedEnv,
163+
...localeEnv,
164+
} as Record<string, string>;
165+
}
106166

107167
function applyAgentEventHookEnv(env: Record<string, string>, ptyId: string): void {
108168
const hookPort = agentEventService.getPort();
@@ -1036,7 +1096,7 @@ export function startSshPty(options: {
10361096
HOME: process.env.HOME || os.homedir(),
10371097
USER: process.env.USER || os.userInfo().username,
10381098
PATH: process.env.PATH || process.env.Path || '',
1039-
...(process.env.LANG && { LANG: process.env.LANG }),
1099+
...getLocaleEnv(),
10401100
...(process.env.TMPDIR && { TMPDIR: process.env.TMPDIR }),
10411101
...getDisplayEnv(),
10421102
...(process.env.SSH_AUTH_SOCK && { SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK }),
@@ -1201,7 +1261,7 @@ export function startDirectPty(options: {
12011261
USER: process.env.USER || os.userInfo().username,
12021262
// Include PATH so CLI can find its dependencies
12031263
PATH: process.env.PATH || process.env.Path || '',
1204-
...(process.env.LANG && { LANG: process.env.LANG }),
1264+
...getLocaleEnv(),
12051265
...(process.env.TMPDIR && { TMPDIR: process.env.TMPDIR }),
12061266
...getDisplayEnv(),
12071267
...(process.env.SSH_AUTH_SOCK && { SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK }),
@@ -1323,21 +1383,20 @@ export async function startPty(options: {
13231383
// tools create clean user environments.
13241384
//
13251385
// See: https://github.com/generalaction/emdash/issues/485
1326-
const useEnv: Record<string, string> = {
1386+
const useEnv = mergeEnvWithNormalizedLocale({
13271387
TERM: 'xterm-256color',
13281388
COLORTERM: 'truecolor',
13291389
TERM_PROGRAM: 'emdash',
13301390
HOME: process.env.HOME || os.homedir(),
13311391
USER: process.env.USER || os.userInfo().username,
13321392
SHELL: process.env.SHELL || defaultShell,
13331393
...(process.platform === 'win32' ? getWindowsEssentialEnv() : {}),
1334-
...(process.env.LANG && { LANG: process.env.LANG }),
13351394
...(process.env.TMPDIR && { TMPDIR: process.env.TMPDIR }),
13361395
...(process.env.DISPLAY && { DISPLAY: process.env.DISPLAY }),
13371396
...getDisplayEnv(),
13381397
...(process.env.SSH_AUTH_SOCK && { SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK }),
13391398
...(env || {}),
1340-
};
1399+
});
13411400

13421401
applyAgentEventHookEnv(useEnv, id);
13431402

@@ -1651,6 +1710,82 @@ export interface LifecyclePtyHandle {
16511710
kill: (signal?: string) => void;
16521711
}
16531712

1713+
export function createUtf8StreamForwarder(emitData: (data: string) => void): {
1714+
pushStdout: (buf: Buffer) => void;
1715+
pushStderr: (buf: Buffer) => void;
1716+
flush: () => void;
1717+
} {
1718+
const stdoutDecoder = new StringDecoder('utf8');
1719+
const stderrDecoder = new StringDecoder('utf8');
1720+
let flushed = false;
1721+
1722+
const emitIfPresent = (data: string) => {
1723+
if (!data) return;
1724+
emitData(data);
1725+
};
1726+
1727+
return {
1728+
pushStdout: (buf: Buffer) => {
1729+
emitIfPresent(stdoutDecoder.write(buf));
1730+
},
1731+
pushStderr: (buf: Buffer) => {
1732+
emitIfPresent(stderrDecoder.write(buf));
1733+
},
1734+
flush: () => {
1735+
if (flushed) return;
1736+
flushed = true;
1737+
emitIfPresent(stdoutDecoder.end());
1738+
emitIfPresent(stderrDecoder.end());
1739+
},
1740+
};
1741+
}
1742+
1743+
type LifecycleSpawnFallbackChild = {
1744+
stdout?: { on: (event: 'data', listener: (buf: Buffer) => void) => void } | null;
1745+
stderr?: { on: (event: 'data', listener: (buf: Buffer) => void) => void } | null;
1746+
on: (event: 'error' | 'exit' | 'close', listener: (...args: any[]) => void) => void;
1747+
};
1748+
1749+
export function attachLifecycleSpawnFallbackHandlers(
1750+
child: LifecycleSpawnFallbackChild,
1751+
callbacks: {
1752+
onData: (data: string) => void;
1753+
onExit: (exitCode: number | null, signal: string | null) => void;
1754+
onError: (error: Error) => void;
1755+
}
1756+
): void {
1757+
const { onData, onExit, onError } = callbacks;
1758+
let didExit = false;
1759+
let exitCode: number | null = null;
1760+
let exitSignal: string | null = null;
1761+
const forwarder = createUtf8StreamForwarder(onData);
1762+
1763+
child.stdout?.on('data', (buf: Buffer) => {
1764+
forwarder.pushStdout(buf);
1765+
});
1766+
child.stderr?.on('data', (buf: Buffer) => {
1767+
forwarder.pushStderr(buf);
1768+
});
1769+
1770+
child.on('error', (error: Error) => {
1771+
forwarder.flush();
1772+
onError(error);
1773+
});
1774+
1775+
child.on('exit', (code: number | null, signal: string | null) => {
1776+
didExit = true;
1777+
exitCode = code;
1778+
exitSignal = signal ?? null;
1779+
});
1780+
1781+
child.on('close', () => {
1782+
// Flush only after stdio closes so buffered UTF-8 bytes can complete.
1783+
forwarder.flush();
1784+
if (!didExit) return;
1785+
onExit(exitCode, exitSignal);
1786+
});
1787+
}
1788+
16541789
function startLifecycleSpawnFallback(options: {
16551790
id: string;
16561791
command: string;
@@ -1664,26 +1799,22 @@ function startLifecycleSpawnFallback(options: {
16641799
cwd: cwd || os.homedir(),
16651800
shell: true,
16661801
detached: true,
1667-
env: { ...process.env, ...(env || {}) },
1802+
env: mergeEnvWithNormalizedLocale(process.env, env),
16681803
});
16691804

16701805
const dataCallbacks: Array<(data: string) => void> = [];
16711806
const exitCallbacks: Array<(exitCode: number | null, signal: string | null) => void> = [];
16721807
const errorCallbacks: Array<(error: Error) => void> = [];
1673-
1674-
const onData = (buf: Buffer) => {
1675-
const str = buf.toString();
1676-
for (const cb of dataCallbacks) cb(str);
1677-
};
1678-
child.stdout?.on('data', onData);
1679-
child.stderr?.on('data', onData);
1680-
1681-
child.on('error', (error: Error) => {
1682-
for (const cb of errorCallbacks) cb(error);
1683-
});
1684-
1685-
child.on('exit', (code, signal) => {
1686-
for (const cb of exitCallbacks) cb(code, signal ?? null);
1808+
attachLifecycleSpawnFallbackHandlers(child, {
1809+
onData: (data) => {
1810+
for (const cb of dataCallbacks) cb(data);
1811+
},
1812+
onExit: (code, signal) => {
1813+
for (const cb of exitCallbacks) cb(code, signal);
1814+
},
1815+
onError: (error) => {
1816+
for (const cb of errorCallbacks) cb(error);
1817+
},
16871818
});
16881819

16891820
return {
@@ -1731,21 +1862,20 @@ export function startLifecyclePty(options: {
17311862
const { id, command, cwd, env } = options;
17321863
const defaultShell = getDefaultShell();
17331864

1734-
const useEnv: Record<string, string> = {
1865+
const useEnv = mergeEnvWithNormalizedLocale({
17351866
TERM: 'xterm-256color',
17361867
COLORTERM: 'truecolor',
17371868
TERM_PROGRAM: 'emdash',
17381869
HOME: process.env.HOME || os.homedir(),
17391870
USER: process.env.USER || os.userInfo().username,
17401871
SHELL: process.env.SHELL || defaultShell,
17411872
...(process.platform === 'win32' ? getWindowsEssentialEnv() : {}),
1742-
...(process.env.LANG && { LANG: process.env.LANG }),
17431873
...(process.env.TMPDIR && { TMPDIR: process.env.TMPDIR }),
17441874
...(process.env.DISPLAY && { DISPLAY: process.env.DISPLAY }),
17451875
...getDisplayEnv(),
17461876
...(process.env.SSH_AUTH_SOCK && { SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK }),
17471877
...(env || {}),
1748-
};
1878+
});
17491879

17501880
const proc = pty.spawn(defaultShell, ['-ilc', command], {
17511881
name: 'xterm-256color',

0 commit comments

Comments
 (0)