diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 1cf7e8dffec..8fa7bbf019e 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1391,6 +1391,36 @@ it.layer( }), ); + it.effect("falls back to a UTF-8 LANG when the host env has no locale", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + env: { PATH: "/usr/bin:/bin" }, + }).pipe(Effect.provide(withHostPlatform("darwin"))); + yield* manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + // Without this fallback a Dock-launched app spawns the PTY in the C + // locale and zsh mangles multibyte input into `?`/`<00xx>` bytes. + expect(spawnInput.env.LANG).toBe("en_US.UTF-8"); + }), + ); + + it.effect("keeps an explicitly configured locale untouched", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + env: { PATH: "/usr/bin:/bin", LANG: "ru_RU.KOI8-R" }, + }).pipe(Effect.provide(withHostPlatform("darwin"))); + yield* manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + expect(spawnInput.env.LANG).toBe("ru_RU.KOI8-R"); + }), + ); + it.effect("injects runtime env overrides into spawned terminals", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index caa5106bb9f..a260e891be3 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1112,9 +1112,24 @@ function stripAppImageRuntimeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return scrubbed; } +// launchd starts GUI apps without LANG/LC_* (Terminal.app injects its own), so +// a packaged app's PTY shell lands in the single-byte C locale and zsh's line +// editor renders multibyte input as raw `?`/`<00xx>` bytes. An explicit LANG or +// LC_ALL — even a non-UTF-8 one — is the user's choice and is left alone. +function withUtf8LocaleFallback( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): NodeJS.ProcessEnv { + if (platform === "win32") return env; + if (env.LC_ALL || env.LANG) return env; + // en_US.UTF-8 always exists on macOS; on Linux only C.UTF-8 is guaranteed. + return { ...env, LANG: platform === "darwin" ? "en_US.UTF-8" : "C.UTF-8" }; +} + function createTerminalSpawnEnv( baseEnv: NodeJS.ProcessEnv, - runtimeEnv?: Record | null, + runtimeEnv: Record | undefined | null, + platform: NodeJS.Platform, ): NodeJS.ProcessEnv { const spawnEnv: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(baseEnv)) { @@ -1127,7 +1142,7 @@ function createTerminalSpawnEnv( spawnEnv[key] = value; } } - return stripAppImageRuntimeEnv(spawnEnv); + return withUtf8LocaleFallback(stripAppImageRuntimeEnv(spawnEnv), platform); } function normalizedRuntimeEnv( @@ -1884,7 +1899,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func Effect.andThen( Effect.gen(function* () { const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); - const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv); + const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv, platform); const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); ptyProcess = spawnResult.process; startedShell = spawnResult.shellLabel;