diff --git a/apps/cli/src/commands/program.ts b/apps/cli/src/commands/program.ts index 11fe2d810f..495b41e96c 100644 --- a/apps/cli/src/commands/program.ts +++ b/apps/cli/src/commands/program.ts @@ -136,6 +136,7 @@ export function commanderToParsedArgs(program: Command): ParsedArgs { interactive: !!opts.tui, outputMode: opts.json ? "json" : "text", mode: opts.plan ? "plan" : opts.yolo ? "yolo" : opts.zen ? "zen" : "act", + modeExplicitlySet: !!(opts.plan || opts.act || opts.yolo || opts.zen), sandbox: !!opts.dataDir, acpMode: !!opts.acp, thinking: false, diff --git a/apps/cli/src/main.test.ts b/apps/cli/src/main.test.ts index 4d3282ac07..631c9c506f 100644 --- a/apps/cli/src/main.test.ts +++ b/apps/cli/src/main.test.ts @@ -1,4 +1,6 @@ -import { fstatSync } from "node:fs"; +import { fstatSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CliMigrationNotice, @@ -18,6 +20,7 @@ vi.mock("node:fs", async () => { const originalArgv = [...process.argv]; const originalStdinIsTTY = process.stdin.isTTY; const originalStdoutIsTTY = process.stdout.isTTY; +const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH; const mockState = vi.hoisted(() => ({ runAgentImports: 0, runInteractiveImports: 0, @@ -216,8 +219,17 @@ vi.mock("./utils/telemetry", () => telemetryMocks); vi.mock("./utils/worktree", () => worktreeMocks); describe("runCli lightweight command dispatch", () => { + let globalSettingsRoot: string | undefined; + beforeEach(() => { process.exitCode = undefined; + // Startup now reads persisted general settings; point the resolver at a + // fresh temp file so the developer's real settings cannot leak in. + globalSettingsRoot = mkdtempSync(join(tmpdir(), "cline-cli-main-test-")); + process.env.CLINE_GLOBAL_SETTINGS_PATH = join( + globalSettingsRoot, + "global-settings.json", + ); mockState.runAgentImports = 0; mockState.runInteractiveImports = 0; mockState.runAgentCalls = 0; @@ -317,6 +329,16 @@ describe("runCli lightweight command dispatch", () => { afterEach(() => { process.exitCode = undefined; + if (originalGlobalSettingsPath === undefined) { + delete process.env.CLINE_GLOBAL_SETTINGS_PATH; + } else { + process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath; + } + if (globalSettingsRoot) { + rmSync(globalSettingsRoot, { recursive: true, force: true }); + globalSettingsRoot = undefined; + } + process.argv = [...originalArgv]; Object.defineProperty(process.stdin, "isTTY", { value: originalStdinIsTTY, @@ -915,6 +937,172 @@ describe("runCli lightweight command dispatch", () => { ); }); + describe("persisted general settings at startup", () => { + function writePersistedSettings(settings: Record) { + const path = process.env.CLINE_GLOBAL_SETTINGS_PATH; + if (!path) { + throw new Error("CLINE_GLOBAL_SETTINGS_PATH is not set"); + } + writeFileSync(path, JSON.stringify(settings)); + } + + it("restores the persisted plan mode when no mode flag is provided", async () => { + writePersistedSettings({ planActMode: "plan" }); + promptMocks.resolveSystemPrompt.mockClear(); + process.argv = ["bun", "src/index.ts"]; + + const { runCli } = await import("./main"); + + await expect(runCli()).resolves.toBeUndefined(); + expect(runtimeMocks.runInteractive).toHaveBeenCalledWith( + expect.objectContaining({ mode: "plan" }), + expect.anything(), + undefined, + expect.any(Object), + ); + expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith( + expect.objectContaining({ mode: "plan" }), + ); + }); + + it("prefers an explicit --act flag over the persisted plan mode", async () => { + writePersistedSettings({ planActMode: "plan" }); + promptMocks.resolveSystemPrompt.mockClear(); + process.argv = ["bun", "src/index.ts", "--act"]; + + const { runCli } = await import("./main"); + + await expect(runCli()).resolves.toBeUndefined(); + expect(runtimeMocks.runInteractive).toHaveBeenCalledWith( + expect.objectContaining({ mode: "act" }), + expect.anything(), + undefined, + expect.any(Object), + ); + expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith( + expect.objectContaining({ mode: "act" }), + ); + }); + + it("restores the persisted auto-approve setting as a runtime policy", async () => { + writePersistedSettings({ toolAutoApprove: false }); + process.argv = ["bun", "src/index.ts"]; + + const { runCli } = await import("./main"); + + await expect(runCli()).resolves.toBeUndefined(); + expect(runtimeMocks.runInteractive).toHaveBeenCalledWith( + expect.objectContaining({ + defaultToolAutoApprove: true, + toolPolicies: { + "*": { autoApprove: false }, + }, + }), + expect.anything(), + undefined, + expect.any(Object), + ); + }); + + it("prefers an explicit --auto-approve flag over the persisted setting", async () => { + writePersistedSettings({ toolAutoApprove: false }); + process.argv = ["bun", "src/index.ts", "--auto-approve", "true"]; + + const { runCli } = await import("./main"); + + await expect(runCli()).resolves.toBeUndefined(); + expect(runtimeMocks.runInteractive).toHaveBeenCalledWith( + expect.objectContaining({ + toolPolicies: { + "*": { autoApprove: true }, + }, + }), + expect.anything(), + undefined, + expect.any(Object), + ); + }); + + it("restores disabled compaction across restarts", async () => { + writePersistedSettings({ + compactionEnabled: false, + compactionStrategy: "basic", + }); + process.argv = ["bun", "src/index.ts"]; + + const { runCli } = await import("./main"); + + await expect(runCli()).resolves.toBeUndefined(); + expect(runtimeMocks.runInteractive).toHaveBeenCalledWith( + expect.objectContaining({ + compaction: { enabled: false }, + }), + expect.anything(), + undefined, + expect.any(Object), + ); + }); + + it("restores the persisted compaction strategy across restarts", async () => { + writePersistedSettings({ + compactionEnabled: true, + compactionStrategy: "basic", + }); + process.argv = ["bun", "src/index.ts"]; + + const { runCli } = await import("./main"); + + await expect(runCli()).resolves.toBeUndefined(); + expect(runtimeMocks.runInteractive).toHaveBeenCalledWith( + expect.objectContaining({ + compaction: { enabled: true, strategy: "basic" }, + }), + expect.anything(), + undefined, + expect.any(Object), + ); + }); + + it("prefers an explicit --compaction flag over the persisted mode", async () => { + writePersistedSettings({ compactionEnabled: false }); + process.argv = ["bun", "src/index.ts", "--compaction", "agentic"]; + + const { runCli } = await import("./main"); + + await expect(runCli()).resolves.toBeUndefined(); + expect(runtimeMocks.runInteractive).toHaveBeenCalledWith( + expect.objectContaining({ + compaction: { enabled: true, strategy: "agentic" }, + }), + expect.anything(), + undefined, + expect.any(Object), + ); + }); + + it("applies persisted settings to single-prompt runs as well", async () => { + writePersistedSettings({ + compactionEnabled: true, + compactionStrategy: "basic", + planActMode: "plan", + }); + forcePromptModeInput(); + process.argv = ["bun", "src/index.ts", "say hello"]; + + const { runCli } = await import("./main"); + + await expect(runCli()).resolves.toBeUndefined(); + expect(runtimeMocks.runAgent).toHaveBeenCalledWith( + "say hello", + expect.objectContaining({ + compaction: { enabled: true, strategy: "basic" }, + mode: "plan", + }), + expect.anything(), + ); + }); + }); + it("forces chat view when resuming a session", async () => { process.argv = ["bun", "src/index.ts", "--id", "sess_123"]; diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 3c2fb5db89..6f55fcab6c 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -43,6 +43,11 @@ import { normalizeProviderId, } from "./utils/provider-auth"; import { resolveCliReasoning } from "./utils/reasoning"; +import { + resolveStartupCompactionMode, + resolveStartupMode, + resolveStartupToolAutoApprove, +} from "./utils/startup-settings"; import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command"; import { captureCliExtensionActivated, @@ -865,14 +870,6 @@ export async function runCli(): Promise { } } setCurrentOutputMode(args.outputMode); - const defaultToolAutoApprove = true; - const effectiveToolAutoApprove = - args.autoApproveOverride ?? defaultToolAutoApprove; - const toolPolicies: Record = { - "*": { - autoApprove: effectiveToolAutoApprove, - }, - }; if (args.outputMode === "json" && (args.interactive || !args.prompt)) { writeErr( @@ -949,6 +946,27 @@ export async function runCli(): Promise { runAgent, } = await loadCliRuntimeModules(); + // General settings toggled in the TUI /settings panel persist to the + // global settings file; explicit CLI flags take precedence over the + // persisted values, which in turn override the built-in defaults. + const persistedGlobalSettings = coreServer.readGlobalSettings(); + const defaultToolAutoApprove = true; + const effectiveToolAutoApprove = resolveStartupToolAutoApprove( + args, + persistedGlobalSettings, + defaultToolAutoApprove, + ); + const toolPolicies: Record = { + "*": { + autoApprove: effectiveToolAutoApprove, + }, + }; + const effectiveMode = resolveStartupMode(args, persistedGlobalSettings); + const effectiveCompactionMode = resolveStartupCompactionMode( + args, + persistedGlobalSettings, + ); + // Register the SDK early logger as early as possible — before any // provider settings reads — so the full startup sequence is captured. // These components operate before/outside ClineCore sessions, so the @@ -1107,13 +1125,13 @@ export async function runCli(): Promise { cwd, explicitSystemPrompt: args.systemPrompt, providerId: provider, - mode: args.mode ?? "act", + mode: effectiveMode, }), execution: { maxConsecutiveMistakes: args.retries ?? 3, }, checkpoint: CLI_DEFAULT_CHECKPOINT_CONFIG, - compaction: buildCliCompactionConfig(args.compactionMode), + compaction: buildCliCompactionConfig(effectiveCompactionMode), timeoutSeconds: args.timeoutSeconds, sandbox: sandboxEnabled, sandboxDataDir, @@ -1121,7 +1139,7 @@ export async function runCli(): Promise { thinking: resolvedReasoning.thinking, reasoningEffort: resolvedReasoning.reasoningEffort, outputMode: args.outputMode, - mode: args.mode, + mode: effectiveMode, logger: loggerAdapter.core, loggerConfig: loggerAdapter.runtimeConfig, telemetry: getCliTelemetryService(loggerAdapter.core), diff --git a/apps/cli/src/runtime/run-interactive.ts b/apps/cli/src/runtime/run-interactive.ts index e2b822127e..6d04183c03 100644 --- a/apps/cli/src/runtime/run-interactive.ts +++ b/apps/cli/src/runtime/run-interactive.ts @@ -2,6 +2,9 @@ import { getCurrentContextSize, type ProviderSettings, ProviderSettingsManager, + setCompactionModeGlobally, + setPlanActModeGlobally, + setToolAutoApproveGlobally, type UserInstructionConfigService, } from "@cline/core"; import { formatModeSwitchNotice } from "@cline/shared"; @@ -712,15 +715,20 @@ export async function runInteractive( onTurnErrorReported: () => {}, onAutoApproveChange: (enabled) => { setInteractiveAutoApprove(enabled); + setToolAutoApproveGlobally(enabled); void refreshInteractiveSessionPolicies(); }, onCompactionModeChange: async (mode) => { await sessionRuntime.ensureReady(); applyCliCompactionMode(config, mode); + setCompactionModeGlobally(mode); await sessionRuntime.restartWithCurrentMessages(); }, onModeChange: async (mode) => { if (!isInteractiveMode(mode)) return; + // Persist the user's choice immediately, even when the switch is + // deferred until the current turn aborts, so it survives restarts. + setPlanActModeGlobally(mode); if (isRunning) { pendingModeChange.current = mode; pendingModeChange.source = "ui"; diff --git a/apps/cli/src/utils/helpers.test.ts b/apps/cli/src/utils/helpers.test.ts index 3869415303..ccccace770 100644 --- a/apps/cli/src/utils/helpers.test.ts +++ b/apps/cli/src/utils/helpers.test.ts @@ -46,6 +46,7 @@ describe("parseArgs", () => { interactive: false, outputMode: "text", mode: "act", + modeExplicitlySet: false, sandbox: false, acpMode: false, thinking: false, @@ -220,6 +221,15 @@ describe("parseArgs", () => { expect(parsedYolo.autoApproveOverride).toBe(true); }); + it("marks explicit mode flags so persisted settings do not override them", () => { + expect(parseArgs([]).modeExplicitlySet).toBe(false); + expect(parseArgs(["Audit the repo"]).modeExplicitlySet).toBe(false); + expect(parseArgs(["--plan"]).modeExplicitlySet).toBe(true); + expect(parseArgs(["--act"]).modeExplicitlySet).toBe(true); + expect(parseArgs(["--yolo"]).modeExplicitlySet).toBe(true); + expect(parseArgs(["--zen", "do it"]).modeExplicitlySet).toBe(true); + }); + it("parses --zen flag for background hub dispatch", () => { const parsedLong = parseArgs(["--zen", "do it"]); expect(parsedLong.mode).toBe("zen"); diff --git a/apps/cli/src/utils/startup-settings.test.ts b/apps/cli/src/utils/startup-settings.test.ts new file mode 100644 index 0000000000..b0d9fddc04 --- /dev/null +++ b/apps/cli/src/utils/startup-settings.test.ts @@ -0,0 +1,113 @@ +import type { GlobalSettings } from "@cline/core"; +import { describe, expect, it } from "vitest"; +import { + resolveStartupCompactionMode, + resolveStartupMode, + resolveStartupToolAutoApprove, +} from "./startup-settings"; + +function makeSettings(overrides: Partial = {}): GlobalSettings { + return { + autoUpdateEnabled: true, + telemetryOptOut: false, + ...overrides, + }; +} + +describe("resolveStartupMode", () => { + it("uses the parsed default when nothing is persisted", () => { + expect( + resolveStartupMode({ mode: "act", modeExplicitlySet: false }, makeSettings()), + ).toBe("act"); + }); + + it("restores the persisted plan/act mode when no mode flag is provided", () => { + expect( + resolveStartupMode( + { mode: "act", modeExplicitlySet: false }, + makeSettings({ planActMode: "plan" }), + ), + ).toBe("plan"); + }); + + it("prefers an explicit mode flag over the persisted mode", () => { + expect( + resolveStartupMode( + { mode: "act", modeExplicitlySet: true }, + makeSettings({ planActMode: "plan" }), + ), + ).toBe("act"); + expect( + resolveStartupMode( + { mode: "yolo", modeExplicitlySet: true }, + makeSettings({ planActMode: "plan" }), + ), + ).toBe("yolo"); + }); +}); + +describe("resolveStartupToolAutoApprove", () => { + it("falls back to the built-in default when nothing is persisted", () => { + expect(resolveStartupToolAutoApprove({}, makeSettings(), true)).toBe(true); + }); + + it("restores the persisted auto-approve setting", () => { + expect( + resolveStartupToolAutoApprove( + {}, + makeSettings({ toolAutoApprove: false }), + true, + ), + ).toBe(false); + }); + + it("prefers an explicit --auto-approve flag over the persisted setting", () => { + expect( + resolveStartupToolAutoApprove( + { autoApproveOverride: true }, + makeSettings({ toolAutoApprove: false }), + true, + ), + ).toBe(true); + expect( + resolveStartupToolAutoApprove( + { autoApproveOverride: false }, + makeSettings({ toolAutoApprove: true }), + true, + ), + ).toBe(false); + }); +}); + +describe("resolveStartupCompactionMode", () => { + it("returns undefined so Core's default applies when nothing is persisted", () => { + expect(resolveStartupCompactionMode({}, makeSettings())).toBeUndefined(); + }); + + it("restores the persisted strategy", () => { + expect( + resolveStartupCompactionMode( + {}, + makeSettings({ compactionEnabled: true, compactionStrategy: "basic" }), + ), + ).toBe("basic"); + }); + + it("restores the off state even when a strategy is retained on disk", () => { + expect( + resolveStartupCompactionMode( + {}, + makeSettings({ compactionEnabled: false, compactionStrategy: "basic" }), + ), + ).toBe("off"); + }); + + it("prefers an explicit --compaction flag over the persisted mode", () => { + expect( + resolveStartupCompactionMode( + { compactionMode: "agentic" }, + makeSettings({ compactionEnabled: false }), + ), + ).toBe("agentic"); + }); +}); diff --git a/apps/cli/src/utils/startup-settings.ts b/apps/cli/src/utils/startup-settings.ts new file mode 100644 index 0000000000..669900305a --- /dev/null +++ b/apps/cli/src/utils/startup-settings.ts @@ -0,0 +1,46 @@ +import type { GlobalSettings } from "@cline/core"; +import type { CliAgentMode, CliCompactionMode, ParsedArgs } from "./types"; + +/** + * Resolves general settings at CLI startup with the precedence + * explicit CLI flag -> persisted global setting -> built-in default, + * so choices made in the TUI /settings panel survive restarts (see + * https://github.com/cline/cline/issues/12158). + */ + +export function resolveStartupMode( + args: Pick, + settings: GlobalSettings, +): CliAgentMode { + if (args.modeExplicitlySet) { + return args.mode; + } + return settings.planActMode ?? args.mode; +} + +export function resolveStartupToolAutoApprove( + args: Pick, + settings: GlobalSettings, + defaultToolAutoApprove: boolean, +): boolean { + return ( + args.autoApproveOverride ?? settings.toolAutoApprove ?? defaultToolAutoApprove + ); +} + +/** + * Returns undefined when neither a flag nor a persisted value exists, so + * callers fall through to Core's compaction default. + */ +export function resolveStartupCompactionMode( + args: Pick, + settings: GlobalSettings, +): CliCompactionMode | undefined { + if (args.compactionMode) { + return args.compactionMode; + } + if (settings.compactionEnabled === false) { + return "off"; + } + return settings.compactionStrategy; +} diff --git a/apps/cli/src/utils/types.ts b/apps/cli/src/utils/types.ts index 703cbb54c7..4b0973e756 100644 --- a/apps/cli/src/utils/types.ts +++ b/apps/cli/src/utils/types.ts @@ -71,6 +71,8 @@ export interface ParsedArgs { interactive: boolean; outputMode: CliOutputMode; mode: CliAgentMode; + /** Whether a mode flag (--plan/--act/--yolo/--zen) was explicitly provided */ + modeExplicitlySet?: boolean; timeoutSeconds?: number; invalidTimeoutSeconds?: string; thinking: boolean; diff --git a/sdk/packages/core/src/index.ts b/sdk/packages/core/src/index.ts index 02a6cf6197..ddd1e8ca1e 100644 --- a/sdk/packages/core/src/index.ts +++ b/sdk/packages/core/src/index.ts @@ -509,7 +509,9 @@ export { NoOpFeatureFlagsProvider, } from "./services/feature-flags"; export type { + GlobalCompactionMode, GlobalCompactionStrategy, + GlobalPlanActMode, GlobalSettings, } from "./services/global-settings"; export { @@ -521,15 +523,21 @@ export { isPluginDisabledGlobally, isTelemetryOptedOutGlobally, isToolDisabledGlobally, + readCompactionModeGlobally, readCompactionStrategyGlobally, readGlobalSettings, + readPlanActModeGlobally, + readToolAutoApproveGlobally, resolveDisabledPluginPaths, resolveDisabledToolNames, setAutoUpdateEnabledGlobally, + setCompactionModeGlobally, setCompactionStrategyGlobally, setDisabledPlugin, setDisabledTools, + setPlanActModeGlobally, setTelemetryOptOutGlobally, + setToolAutoApproveGlobally, toggleDisabledTool, writeGlobalSettings, } from "./services/global-settings"; diff --git a/sdk/packages/core/src/services/global-settings.test.ts b/sdk/packages/core/src/services/global-settings.test.ts index 7054ef60c0..a0a796bd02 100644 --- a/sdk/packages/core/src/services/global-settings.test.ts +++ b/sdk/packages/core/src/services/global-settings.test.ts @@ -5,13 +5,19 @@ import type { ITelemetryService } from "@cline/shared"; import { afterEach, describe, expect, it, vi } from "vitest"; import { GlobalSettingsSchema, + readCompactionModeGlobally, readCompactionStrategyGlobally, readGlobalSettings, + readPlanActModeGlobally, + readToolAutoApproveGlobally, setAutoUpdateEnabledGlobally, + setCompactionModeGlobally, setCompactionStrategyGlobally, setDisabledPlugin, setDisabledTools, + setPlanActModeGlobally, setTelemetryOptOutGlobally, + setToolAutoApproveGlobally, writeGlobalSettings, } from "./global-settings"; @@ -217,6 +223,105 @@ describe("global-settings", () => { } }); + it("normalizes the persisted general settings fields", () => { + expect( + GlobalSettingsSchema.parse({ + compactionEnabled: false, + planActMode: "plan", + toolAutoApprove: false, + }), + ).toEqual({ + autoUpdateEnabled: true, + compactionEnabled: false, + planActMode: "plan", + telemetryOptOut: false, + toolAutoApprove: false, + }); + // Invalid values fall back to unset instead of failing the whole parse. + expect( + GlobalSettingsSchema.parse({ + compactionEnabled: "yes", + planActMode: "chaos", + toolAutoApprove: 42, + }), + ).toEqual({ + autoUpdateEnabled: true, + telemetryOptOut: false, + }); + }); + + it("reads and writes the plan/act mode globally", async () => { + const root = await mkdtemp(join(tmpdir(), "core-global-settings-")); + try { + const settingsPath = join(root, "global-settings.json"); + process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath; + + expect(readPlanActModeGlobally()).toBeUndefined(); + setPlanActModeGlobally("plan"); + expect(readPlanActModeGlobally()).toBe("plan"); + setPlanActModeGlobally("act"); + expect(readPlanActModeGlobally()).toBe("act"); + expect(JSON.parse(await readFile(settingsPath, "utf8"))).toEqual({ + autoUpdateEnabled: true, + planActMode: "act", + telemetryOptOut: false, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("reads and writes the tool auto-approve setting globally", async () => { + const root = await mkdtemp(join(tmpdir(), "core-global-settings-")); + try { + const settingsPath = join(root, "global-settings.json"); + process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath; + + expect(readToolAutoApproveGlobally()).toBeUndefined(); + setToolAutoApproveGlobally(false); + expect(readToolAutoApproveGlobally()).toBe(false); + setToolAutoApproveGlobally(true); + expect(readToolAutoApproveGlobally()).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("round-trips the compaction mode including the off state", async () => { + const root = await mkdtemp(join(tmpdir(), "core-global-settings-")); + try { + const settingsPath = join(root, "global-settings.json"); + process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath; + + expect(readCompactionModeGlobally()).toBeUndefined(); + + setCompactionModeGlobally("basic"); + expect(readCompactionModeGlobally()).toBe("basic"); + + // Turning compaction off retains the previous strategy on disk so + // re-enabling restores it. + setCompactionModeGlobally("off"); + expect(readCompactionModeGlobally()).toBe("off"); + expect(readGlobalSettings()).toEqual({ + autoUpdateEnabled: true, + compactionEnabled: false, + compactionStrategy: "basic", + telemetryOptOut: false, + }); + + setCompactionModeGlobally("agentic"); + expect(readCompactionModeGlobally()).toBe("agentic"); + expect(readGlobalSettings()).toEqual({ + autoUpdateEnabled: true, + compactionEnabled: true, + compactionStrategy: "agentic", + telemetryOptOut: false, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + describe("caching", () => { it("invalidates the cache when writeGlobalSettings is called", async () => { const root = await mkdtemp(join(tmpdir(), "core-global-settings-")); diff --git a/sdk/packages/core/src/services/global-settings.ts b/sdk/packages/core/src/services/global-settings.ts index dca8e55f9d..fa598e6dfc 100644 --- a/sdk/packages/core/src/services/global-settings.ts +++ b/sdk/packages/core/src/services/global-settings.ts @@ -37,11 +37,21 @@ export type GlobalCompactionStrategy = z.infer< typeof GlobalCompactionStrategySchema >; +/** Compaction strategy plus the "off" state surfaced by the CLI settings UI. */ +export type GlobalCompactionMode = GlobalCompactionStrategy | "off"; + +const GlobalPlanActModeSchema = z.enum(["plan", "act"]); + +export type GlobalPlanActMode = z.infer; + export const GlobalSettingsSchema = z .object({ telemetryOptOut: z.boolean().default(false).catch(false), autoUpdateEnabled: z.boolean().default(true).catch(true), compactionStrategy: GlobalCompactionStrategySchema.optional(), + compactionEnabled: z.boolean().optional().catch(undefined), + planActMode: GlobalPlanActModeSchema.optional().catch(undefined), + toolAutoApprove: z.boolean().optional().catch(undefined), disabledTools: GlobalSettingsStringListSchema.optional(), disabledPlugins: GlobalSettingsStringListSchema.optional(), }) @@ -51,6 +61,9 @@ export const GlobalSettingsSchema = z telemetryOptOut: boolean; autoUpdateEnabled: boolean; compactionStrategy?: GlobalCompactionStrategy; + compactionEnabled?: boolean; + planActMode?: GlobalPlanActMode; + toolAutoApprove?: boolean; disabledTools?: string[]; disabledPlugins?: string[]; } = { @@ -60,6 +73,15 @@ export const GlobalSettingsSchema = z if (settings.compactionStrategy) { normalized.compactionStrategy = settings.compactionStrategy; } + if (settings.compactionEnabled !== undefined) { + normalized.compactionEnabled = settings.compactionEnabled; + } + if (settings.planActMode) { + normalized.planActMode = settings.planActMode; + } + if (settings.toolAutoApprove !== undefined) { + normalized.toolAutoApprove = settings.toolAutoApprove; + } if (settings.disabledTools?.length) { normalized.disabledTools = settings.disabledTools; } @@ -203,6 +225,51 @@ export function setCompactionStrategyGlobally( writeGlobalSettings({ ...readGlobalSettings(), compactionStrategy }); } +/** + * Returns the persisted compaction mode including the disabled state, or + * undefined when the user never chose one (callers apply their own default). + */ +export function readCompactionModeGlobally(): GlobalCompactionMode | undefined { + const settings = readGlobalSettings(); + if (settings.compactionEnabled === false) { + return "off"; + } + return settings.compactionStrategy; +} + +/** + * Persists the full compaction mode. Selecting "off" keeps the previously + * chosen strategy so re-enabling compaction restores it. + */ +export function setCompactionModeGlobally(mode: GlobalCompactionMode): void { + const settings = readGlobalSettings(); + if (mode === "off") { + writeGlobalSettings({ ...settings, compactionEnabled: false }); + return; + } + writeGlobalSettings({ + ...settings, + compactionEnabled: true, + compactionStrategy: mode, + }); +} + +export function readPlanActModeGlobally(): GlobalPlanActMode | undefined { + return readGlobalSettings().planActMode; +} + +export function setPlanActModeGlobally(planActMode: GlobalPlanActMode): void { + writeGlobalSettings({ ...readGlobalSettings(), planActMode }); +} + +export function readToolAutoApproveGlobally(): boolean | undefined { + return readGlobalSettings().toolAutoApprove; +} + +export function setToolAutoApproveGlobally(toolAutoApprove: boolean): void { + writeGlobalSettings({ ...readGlobalSettings(), toolAutoApprove }); +} + export function resolveDisabledToolNames( disabledToolNames?: ReadonlyArray, ): Set { diff --git a/sdk/packages/core/src/types.ts b/sdk/packages/core/src/types.ts index b89200dc22..bb5f0bba28 100644 --- a/sdk/packages/core/src/types.ts +++ b/sdk/packages/core/src/types.ts @@ -128,19 +128,29 @@ export { isPluginDisabledGlobally, isTelemetryOptedOutGlobally, isToolDisabledGlobally, + readCompactionModeGlobally, readCompactionStrategyGlobally, readGlobalSettings, + readPlanActModeGlobally, + readToolAutoApproveGlobally, resolveDisabledPluginPaths, resolveDisabledToolNames, setAutoUpdateEnabledGlobally, + setCompactionModeGlobally, setCompactionStrategyGlobally, setDisabledPlugin, setDisabledTools, + setPlanActModeGlobally, setTelemetryOptOutGlobally, + setToolAutoApproveGlobally, toggleDisabledTool, writeGlobalSettings, } from "./services/global-settings"; -export type { GlobalCompactionStrategy } from "./services/global-settings"; +export type { + GlobalCompactionMode, + GlobalCompactionStrategy, + GlobalPlanActMode, +} from "./services/global-settings"; export type { ListPluginToolsResult, PluginToolSummary,