diff --git a/.changeset/fix-gh-tz-windows-console.md b/.changeset/fix-gh-tz-windows-console.md new file mode 100644 index 00000000000..f907bbf84aa --- /dev/null +++ b/.changeset/fix-gh-tz-windows-console.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Set `TZ` when spawning `gh` on Windows to prevent `tzutil.exe` console windows from flashing over fullscreen applications. diff --git a/packages/kilo-vscode/src/agent-manager/shell-env.ts b/packages/kilo-vscode/src/agent-manager/shell-env.ts index 9644f65d2f7..a93fe3b6a55 100644 --- a/packages/kilo-vscode/src/agent-manager/shell-env.ts +++ b/packages/kilo-vscode/src/agent-manager/shell-env.ts @@ -13,6 +13,7 @@ import { type ExecFileOptionsWithStringEncoding } from "child_process" import * as os from "os" +import * as path from "path" import { exec } from "../util/process" // Environment variable keys match: letters, digits, underscores, starting with a non-digit. @@ -28,6 +29,43 @@ const FALLBACK_TTL = 10_000 let fixing: Promise | null = null let fixed = false +/** Inferred IANA time zone, cached because Intl resolution is not free. */ +let inferredTz: string | undefined + +function isGh(cmd: string): boolean { + const ext = path.extname(cmd) + const name = ext ? cmd.slice(0, -ext.length) : cmd + return path.basename(name) === "gh" +} + +function getTimeZone(): string | undefined { + return process.env.TZ || (inferredTz ??= Intl.DateTimeFormat().resolvedOptions().timeZone) +} + +function currentEnv(): Record { + const env: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") env[key] = value + } + return env +} + +function withTzEnv( + cmd: string, + options?: Omit, +): Omit { + if (process.platform !== "win32") return options ?? {} + if (!isGh(cmd)) return options ?? {} + if (options?.env?.TZ) return options + + const tz = getTimeZone() + if (!tz) return options ?? {} + + const env = options?.env ? { ...options.env } : currentEnv() + env.TZ = tz + return { ...options, env } +} + /** * Parse `env` output, handling multiline variable values correctly. * @@ -133,8 +171,9 @@ export async function execWithShellEnv( args: string[], options?: Omit, ): Promise<{ stdout: string; stderr: string }> { + const opts = withTzEnv(cmd, options) try { - return await exec(cmd, args, options) + return await exec(cmd, args, opts) } catch (error) { if ( process.platform !== "darwin" || @@ -148,13 +187,13 @@ export async function execWithShellEnv( // Already resolved and PATH was actually changed — no point retrying resolution. // Just retry with the (already-patched) process.env. if (fixed) { - return await exec(cmd, args, options) + return await exec(cmd, args, opts) } // If another caller is already resolving, wait for it then retry. if (fixing) { await fixing - return await exec(cmd, args, options) + return await exec(cmd, args, opts) } console.log(`[shell-env] "${cmd}" not found, resolving shell environment`) @@ -166,7 +205,7 @@ export async function execWithShellEnv( fixing = null } - return await exec(cmd, args, options) + return await exec(cmd, args, opts) } } diff --git a/packages/kilo-vscode/tests/unit/shell-env.test.ts b/packages/kilo-vscode/tests/unit/shell-env.test.ts index e3e2520866d..d07d5153146 100644 --- a/packages/kilo-vscode/tests/unit/shell-env.test.ts +++ b/packages/kilo-vscode/tests/unit/shell-env.test.ts @@ -1,10 +1,33 @@ import { afterEach, describe, expect, it } from "bun:test" +import * as fs from "fs" +import * as os from "os" +import * as path from "path" import { getShellEnvironment, execWithShellEnv, clearShellEnvCache } from "../../src/agent-manager/shell-env" +let platformDesc: PropertyDescriptor | undefined +const originalTz = process.env.TZ + afterEach(() => { clearShellEnvCache() + if (platformDesc) Object.defineProperty(process, "platform", platformDesc) + if (originalTz === undefined) delete process.env.TZ + else process.env.TZ = originalTz }) +function setPlatform(value: string) { + platformDesc = Object.getOwnPropertyDescriptor(process, "platform") + Object.defineProperty(process, "platform", { value, configurable: true }) +} + +function fakeGhBin(): { dir: string; cleanup: () => void } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-tz-")) + fs.symlinkSync(process.execPath, path.join(dir, "gh")) + return { + dir, + cleanup: () => fs.rmSync(dir, { recursive: true, force: true }), + } +} + describe("getShellEnvironment", () => { it("returns an object with PATH", async () => { const env = await getShellEnvironment() @@ -74,3 +97,76 @@ describe("clearShellEnvCache", () => { expect(second.PATH).toBeDefined() }) }) + +describe("execWithShellEnv TZ for gh on Windows", () => { + it("injects TZ into gh child processes on Windows", async () => { + setPlatform("win32") + process.env.TZ = "Test/TZ" + const { dir, cleanup } = fakeGhBin() + try { + const { stdout } = await execWithShellEnv("gh", ["-e", "console.log(process.env.TZ)"], { + env: { PATH: dir }, + }) + expect(stdout.trim()).toBe("Test/TZ") + } finally { + cleanup() + } + }) + + it("infers a TZ value from the system when process.env.TZ is not set", async () => { + setPlatform("win32") + delete process.env.TZ + const { dir, cleanup } = fakeGhBin() + try { + const { stdout } = await execWithShellEnv("gh", ["-e", "console.log(process.env.TZ)"], { + env: { PATH: dir }, + }) + expect(stdout.trim()).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone) + } finally { + cleanup() + } + }) + + it("does not inject TZ for non-gh commands on Windows", async () => { + setPlatform("win32") + process.env.TZ = "Test/TZ" + const { dir, cleanup } = fakeGhBin() + try { + fs.symlinkSync(process.execPath, path.join(dir, "git")) + const { stdout } = await execWithShellEnv("git", ["-e", "console.log(process.env.TZ)"], { + env: { PATH: dir }, + }) + expect(stdout.trim()).toBe("undefined") + } finally { + cleanup() + } + }) + + it("does not inject TZ for gh on non-Windows platforms", async () => { + setPlatform("linux") + process.env.TZ = "Test/TZ" + const { dir, cleanup } = fakeGhBin() + try { + const { stdout } = await execWithShellEnv("gh", ["-e", "console.log(process.env.TZ)"], { + env: { PATH: dir }, + }) + expect(stdout.trim()).toBe("undefined") + } finally { + cleanup() + } + }) + + it("preserves an explicit TZ in options.env", async () => { + setPlatform("win32") + process.env.TZ = "Test/TZ" + const { dir, cleanup } = fakeGhBin() + try { + const { stdout } = await execWithShellEnv("gh", ["-e", "console.log(process.env.TZ)"], { + env: { PATH: dir, TZ: "Custom/Zone" }, + }) + expect(stdout.trim()).toBe("Custom/Zone") + } finally { + cleanup() + } + }) +})