返回源码地图

packages/coding-agent/src/core/settings-manager.ts

v1.0.0 · a13d35a742c6 · 08/14:inMemory的项目trust默认与设置;非全部迁移审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2import { DEFAULT_MAX_AGENT_RETRY_DELAY_MS, type Model, type Transport } from "@earendil-works/pi-ai";
3import type {
4 TuiMode as RendererTuiMode,
5 ScrollViewScrollbar,
6 TerminalCapabilities,
7 WheelScrollLines,
8} from "@earendil-works/pi-tui";
9import { randomUUID } from "crypto";
10import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
11import { dirname, join } from "path";
12import lockfile from "proper-lockfile";
13import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts";
14import { normalizePath, resolvePath } from "../utils/paths.ts";
15import { stripBom } from "../utils/text.ts";
16import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts";
17
18export interface CompactionModelOverride {
19 reserveTokens?: number;
20 keepRecentTokens?: number;
21}
22
23const DEFAULT_COMPACTION_TOKEN_SETTINGS: Required<CompactionModelOverride> = {
24 reserveTokens: 16384,
25 keepRecentTokens: 20000,
26};
27
28export interface CompactionSettings {
29 enabled?: boolean; // default: true
30 reserveTokens?: number; // default: 16384
31 keepRecentTokens?: number; // default: 20000
32 modelOverrides?: Record<string, CompactionModelOverride>; // exact "provider/modelId" keys
33}
34
35export interface BranchSummarySettings {
36 reserveTokens?: number; // default: 16384 (tokens reserved for prompt + LLM response)
37 skipPrompt?: boolean; // default: false - when true, skips "Summarize branch?" prompt and defaults to no summary
38}
39
40export interface ProviderRetrySettings {
41 timeoutMs?: number; // SDK/provider request timeout in milliseconds
42 maxRetries?: number; // SDK/provider retry attempts
43 maxRetryDelayMs?: number; // default: 60000 (max server-requested delay before failing)
44}
45
46export interface RetrySettings {
47 enabled?: boolean; // default: true
48 maxRetries?: number; // default: 3
49 baseDelayMs?: number; // default: 2000 (exponential backoff: 2s, 4s, 8s)
50 maxAgentDelayMs?: number; // default: 60000
51 provider?: ProviderRetrySettings;
52}
53
54export type TuiMode = RendererTuiMode;
55export type FullscreenExitOutput = "transcript" | "resume-hint";
56
57export interface TerminalSettings {
58 showImages?: boolean; // default: true (only relevant if terminal supports images)
59 imageWidthCells?: number; // default: 60 (preferred inline image width in terminal cells)
60 clearOnShrink?: boolean; // default: false (clear empty rows when content shrinks)
61 showTerminalProgress?: boolean; // default: false (OSC 9;4 terminal progress indicators)
62 hyperlinks?: boolean | "auto";
63 images?: "kitty" | "iterm2" | "auto" | false;
64 trueColor?: boolean | "auto";
65}
66
67export interface ImageSettings {
68 autoResize?: boolean; // default: true (resize images to 2000x2000 max for better model compatibility)
69 blockImages?: boolean; // default: false - when true, prevents all images from being sent to LLM providers
70}
71
72export interface ThinkingBudgetsSettings {
73 minimal?: number;
74 low?: number;
75 medium?: number;
76 high?: number;
77}
78
79export type MermaidRenderingMode = "off" | "final" | "streaming";
80
81/** Cache-warming profile. "idle" also warms between agent runs. */
82export const CACHE_WARMING_MODES = ["off", "streaming", "idle"] as const;
83export type CacheWarmingMode = (typeof CACHE_WARMING_MODES)[number];
84
85export interface MarkdownSettings {
86 codeBlockIndent?: string; // default: " "
87 mermaid?: MermaidRenderingMode; // default: "streaming"
88}
89
90export interface WarningSettings {
91 anthropicExtraUsage?: boolean; // default: true
92}
93
94/**
95 * How the codemode tool presents tools while it is active.
96 * - `on`: declared tools that scripts can call get a note on calling them from scripts appended to
97 * their description; the codemode description lists only the tools without `direct` exposure.
98 * - `only`: the codemode description lists every tool scripts can call, and active `direct` tools are
99 * not declared to the model.
100 */
101export type CodemodeMode = "on" | "only";
102
103export interface CodemodeSettings {
104 /** Default: `on`. */
105 mode?: CodemodeMode;
106 /** Estimated tokens (characters / 4) the codemode description may spend on tool declarations. Default: 3000. */
107 inlineBudget?: number;
108}
109
110export type DefaultProjectTrust = "ask" | "always" | "never";
111/** true hides all startup output, "header" keeps only the startup header. */
112export type QuietStartup = boolean | "header";
113
114export type TransportSetting = Transport;
115
116/**
117 * Package source for npm/git packages.
118 * - String form: load all resources from the package
119 * - Object form: filter which resources to load
120 * - autoload=false: start empty and only apply explicit resource patterns
121 */
122export type PackageSource =
123 | string
124 | {
125 source: string;
126 autoload?: boolean;
127 extensions?: string[];
128 skills?: string[];
129 prompts?: string[];
130 themes?: string[];
131 };
132
133export interface Settings {
134 lastChangelogVersion?: string;
135 defaultProvider?: string;
136 defaultModel?: string;
137 defaultThinkingLevel?: ThinkingLevel;
138 modelThinkingLevels?: Record<string, ThinkingLevel>; // per-model default thinking level overrides keyed by "provider/modelId"
139 transport?: TransportSetting; // default: "auto"
140 steeringMode?: "all" | "one-at-a-time";
141 followUpMode?: "all" | "one-at-a-time";
142 theme?: string;
143 compaction?: CompactionSettings;
144 branchSummary?: BranchSummarySettings;
145 retry?: RetrySettings;
146 hideThinkingBlock?: boolean;
147 showCacheMissNotices?: boolean; // default: false - show cache cost and provider recovery notices
148 externalEditor?: string; // Command for Ctrl+G external editor; takes precedence over VISUAL/EDITOR
149 shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows); supports leading ~ expansion
150 quietStartup?: QuietStartup; // default: false
151 defaultProjectTrust?: DefaultProjectTrust; // default: "ask"; global setting only
152 shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support)
153 npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"])
154 collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
155 enableInstallTelemetry?: boolean; // default: true - anonymous version/update ping after changelog-detected updates
156 enableAnalytics?: boolean; // default: false - opt-in analytics data sharing
157 trackingId?: string; // analytics tracking identifier, generated when analytics is enabled
158 deviceId?: string; // stable UUID of this installation, created when a login first needs it; global setting only
159 packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering)
160 extensions?: string[]; // Array of local extension file paths or directories
161 skills?: string[]; // Array of local skill file paths or directories
162 prompts?: string[]; // Array of local prompt template paths or directories
163 themes?: string[]; // Array of local theme file paths or directories
164 enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands
165 terminal?: TerminalSettings;
166 images?: ImageSettings;
167 enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag)
168 defaultTools?: string[]; // Initial tool selection; `+name`/`-name` entries add to or remove from the inherited selection
169 doubleEscapeAction?: "fork" | "tree" | "none"; // Action for double-escape with empty editor (default: "tree")
170 treeFilterMode?: "default" | "no-tools" | "user-only" | "labeled-only" | "all"; // Default filter when opening /tree
171 thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels
172 editorPaddingX?: number; // Horizontal padding for input editor (default: 0)
173 outputPad?: 0 | 1; // Horizontal padding for chat message output (default: 1)
174 autocompleteMaxVisible?: number; // Max visible items in autocomplete dropdown (default: 5)
175 showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME
176 markdown?: MarkdownSettings;
177 warnings?: WarningSettings;
178 codemode?: CodemodeSettings;
179 sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag)
180 httpProxy?: string; // Proxy URL applied as HTTP_PROXY and HTTPS_PROXY for Pi-managed HTTP clients
181 httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it
182 cacheWarming?: CacheWarmingMode; // default: "streaming"; global only because each refresh costs money
183 websocketConnectTimeoutMs?: number; // WebSocket connect/open handshake timeout in milliseconds; 0 disables it
184 tuiMode?: TuiMode; // default: "fullscreen"
185 fullscreenExitOutput?: FullscreenExitOutput; // default: "transcript"; no effect in regular TUI mode
186 fullscreenScrollbar?: ScrollViewScrollbar; // default: "auto"; no effect in regular TUI mode
187 fullscreenCopyOnSelect?: boolean; // default: true; no effect in regular TUI mode
188 fullscreenWheelScrollLines?: WheelScrollLines; // default: "auto"; lines per wheel event, 1-100
189}
190
191function isMergeableObject(value: unknown): value is Record<string, unknown> {
192 return typeof value === "object" && value !== null && !Array.isArray(value);
193}
194
195function deepMergeObjects(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, unknown> {
196 const result = { ...base };
197
198 for (const key of Object.keys(overrides)) {
199 const overrideValue = overrides[key];
200 if (overrideValue === undefined) {
201 continue;
202 }
203
204 const baseValue = base[key];
205 result[key] =
206 isMergeableObject(baseValue) && isMergeableObject(overrideValue)
207 ? deepMergeObjects(baseValue, overrideValue)
208 : overrideValue;
209 }
210
211 return result;
212}
213
214/** Tools enabled at startup when `defaultTools` does not change them. */
215export const DEFAULT_TOOL_NAMES: readonly string[] = ["read", "bash", "edit", "write"];
216
217function isToolModifier(entry: unknown): boolean {
218 return typeof entry === "string" && (entry.startsWith("+") || entry.startsWith("-"));
219}
220
221/**
222 * Merge `defaultTools` of two settings layers. A list with plain tool names replaces the inherited
223 * one; a list of only `+name`/`-name` entries is appended, so it modifies the inherited selection.
224 */
225function mergeDefaultTools(base: string[] | undefined, overrides: string[] | undefined): string[] | undefined {
226 if (overrides === undefined) return base;
227 // Settings files are not validated; a malformed value replaces instead of throwing here.
228 if (!Array.isArray(base) || !Array.isArray(overrides) || !overrides.every(isToolModifier)) return overrides;
229 return [...base, ...overrides];
230}
231
232/**
233 * Resolve a merged `defaultTools` list: plain names replace `DEFAULT_TOOL_NAMES`, then `+name` adds
234 * and `-name` removes a tool, in list order.
235 */
236function resolveDefaultTools(entries: string[]): string[] {
237 const plain = entries.filter((entry) => !isToolModifier(entry));
238 const tools = plain.length > 0 || entries.length === 0 ? plain : [...DEFAULT_TOOL_NAMES];
239 for (const entry of entries) {
240 if (!isToolModifier(entry)) continue;
241 const name = entry.slice(1);
242 const index = tools.indexOf(name);
243 if (entry.startsWith("+") && index === -1 && name) tools.push(name);
244 else if (entry.startsWith("-") && index !== -1) tools.splice(index, 1);
245 }
246 return tools;
247}
248
249/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
250function deepMergeSettings(base: Settings, overrides: Settings): Settings {
251 const merged = deepMergeObjects(base as Record<string, unknown>, overrides as Record<string, unknown>) as Settings;
252 const defaultTools = mergeDefaultTools(base.defaultTools, overrides.defaultTools);
253 return defaultTools === undefined ? merged : { ...merged, defaultTools };
254}
255
256function parseTimeoutSetting(value: unknown, settingName: string): number | undefined {
257 const timeoutMs = parseHttpIdleTimeoutMs(value);
258 if (timeoutMs !== undefined) {
259 return timeoutMs;
260 }
261 if (value !== undefined) {
262 throw new Error(`Invalid ${settingName} setting: ${String(value)}`);
263 }
264 return undefined;
265}
266
267export type SettingsScope = "global" | "project";
268
269export interface SettingsManagerCreateOptions {
270 projectTrusted?: boolean;
271}
272
273export interface SettingsStorage {
274 withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void;
275}
276
277export interface SettingsError {
278 scope: SettingsScope;
279 path?: string;
280 error: Error;
281}
282
283type SettingsPaths = Partial<Record<SettingsScope, string>>;
284
285function toSettingsError(scope: SettingsScope, error: unknown, path?: string): SettingsError {
286 return {
287 scope,
288 ...(path ? { path } : {}),
289 error: error instanceof Error ? error : new Error(String(error)),
290 };
291}
292
293export class FileSettingsStorage implements SettingsStorage {
294 private globalSettingsPath: string;
295 private projectSettingsPath: string;
296
297 constructor(cwd: string, agentDir: string) {
298 const resolvedCwd = resolvePath(cwd);
299 const resolvedAgentDir = resolvePath(agentDir);
300 this.globalSettingsPath = join(resolvedAgentDir, "settings.json");
301 this.projectSettingsPath = join(resolvedCwd, CONFIG_DIR_NAME, "settings.json");
302 }
303
304 private acquireLockSyncWithRetry(path: string): () => void {
305 const maxAttempts = 10;
306 const delayMs = 20;
307 let lastError: unknown;
308
309 for (let attempt = 1; attempt <= maxAttempts; attempt++) {
310 try {
311 return lockfile.lockSync(path, { realpath: false });
312 } catch (error) {
313 const code =
314 typeof error === "object" && error !== null && "code" in error
315 ? String((error as { code?: unknown }).code)
316 : undefined;
317 if (code !== "ELOCKED" || attempt === maxAttempts) {
318 throw error;
319 }
320 lastError = error;
321 const start = Date.now();
322 while (Date.now() - start < delayMs) {
323 // Sleep synchronously to avoid changing callers to async.
324 }
325 }
326 }
327
328 throw (lastError as Error) ?? new Error("Failed to acquire settings lock");
329 }
330
331 withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void {
332 const path = scope === "global" ? this.globalSettingsPath : this.projectSettingsPath;
333 const dir = dirname(path);
334
335 let release: (() => void) | undefined;
336 try {
337 // Only create directory and lock if file exists or we need to write
338 const fileExists = existsSync(path);
339 if (fileExists) {
340 release = this.acquireLockSyncWithRetry(path);
341 }
342 const current = fileExists ? readFileSync(path, "utf-8") : undefined;
343 const next = fn(current);
344 if (next !== undefined) {
345 // Only create directory when we actually need to write
346 if (!existsSync(dir)) {
347 mkdirSync(dir, { recursive: true });
348 }
349 if (!release) {
350 release = this.acquireLockSyncWithRetry(path);
351 }
352 writeFileSync(path, next, "utf-8");
353 }
354 } finally {
355 if (release) {
356 release();
357 }
358 }
359 }
360}
361
362export class InMemorySettingsStorage implements SettingsStorage {
363 private global: string | undefined;
364 private project: string | undefined;
365
366 withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void {
367 const current = scope === "global" ? this.global : this.project;
368 const next = fn(current);
369 if (next !== undefined) {
370 if (scope === "global") {
371 this.global = next;
372 } else {
373 this.project = next;
374 }
375 }
376 }
377}
378
379export class SettingsManager {
380 private storage: SettingsStorage;
381 private globalSettings: Settings;
382 private projectSettings: Settings;
383 private settings: Settings;
384 private projectTrusted: boolean;
385 private modifiedFields = new Set<keyof Settings>(); // Track global fields modified during session
386 private modifiedNestedFields = new Map<keyof Settings, Set<string>>(); // Track global nested field modifications
387 private modifiedProjectFields = new Set<keyof Settings>(); // Track project fields modified during session
388 private modifiedProjectNestedFields = new Map<keyof Settings, Set<string>>(); // Track project nested field modifications
389 private globalSettingsLoadError: Error | null = null; // Track if global settings file had parse errors
390 private projectSettingsLoadError: Error | null = null; // Track if project settings file had parse errors
391 private writeQueue: Promise<void> = Promise.resolve();
392 private errors: SettingsError[];
393 private settingsPaths: SettingsPaths;
394
395 private constructor(
396 storage: SettingsStorage,
397 initialGlobal: Settings,
398 initialProject: Settings,
399 globalLoadError: Error | null = null,
400 projectLoadError: Error | null = null,
401 initialErrors: SettingsError[] = [],
402 projectTrusted = true,
403 settingsPaths: SettingsPaths = {},
404 ) {
405 this.storage = storage;
406 this.globalSettings = initialGlobal;
407 this.projectSettings = initialProject;
408 this.projectTrusted = projectTrusted;
409 this.globalSettingsLoadError = globalLoadError;
410 this.projectSettingsLoadError = projectLoadError;
411 this.errors = [...initialErrors];
412 this.settingsPaths = settingsPaths;
413 this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
414 }
415
416 /** Create a SettingsManager that loads from files */
417 static create(
418 cwd: string,
419 agentDir: string = getAgentDir(),
420 options: SettingsManagerCreateOptions = {},
421 ): SettingsManager {
422 const resolvedCwd = resolvePath(cwd);
423 const resolvedAgentDir = resolvePath(agentDir);
424 const storage = new FileSettingsStorage(resolvedCwd, resolvedAgentDir);
425 return SettingsManager.fromStorageWithPaths(storage, options, {
426 global: join(resolvedAgentDir, "settings.json"),
427 project: join(resolvedCwd, CONFIG_DIR_NAME, "settings.json"),
428 });
429 }
430
431 /** Create a SettingsManager from an arbitrary storage backend */
432 static fromStorage(storage: SettingsStorage, options: SettingsManagerCreateOptions = {}): SettingsManager {
433 return SettingsManager.fromStorageWithPaths(storage, options);
434 }
435
436 /** Create a manager while retaining optional file paths for reported storage errors. */
437 private static fromStorageWithPaths(
438 storage: SettingsStorage,
439 options: SettingsManagerCreateOptions,
440 settingsPaths: SettingsPaths = {},
441 ): SettingsManager {
442 const projectTrusted = options.projectTrusted ?? true;
443 const globalLoad = SettingsManager.tryLoadFromStorage(storage, "global");
444 const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project", projectTrusted);
445 const initialErrors: SettingsError[] = [];
446 if (globalLoad.error) {
447 initialErrors.push(toSettingsError("global", globalLoad.error, settingsPaths.global));
448 }
449 if (projectLoad.error) {
450 initialErrors.push(toSettingsError("project", projectLoad.error, settingsPaths.project));
451 }
452
453 return new SettingsManager(
454 storage,
455 globalLoad.settings,
456 projectLoad.settings,
457 globalLoad.error,
458 projectLoad.error,
459 initialErrors,
460 projectTrusted,
461 settingsPaths,
462 );
463 }
464
465 /** Create an in-memory SettingsManager (no file I/O) */
466 static inMemory(settings: Partial<Settings> = {}, options: SettingsManagerCreateOptions = {}): SettingsManager {
467 const storage = new InMemorySettingsStorage();
468 const initialSettings = SettingsManager.migrateSettings(structuredClone(settings) as Record<string, unknown>);
469 storage.withLock("global", () => JSON.stringify(initialSettings, null, 2));
470 return SettingsManager.fromStorage(storage, options);
471 }
472
473 private static loadFromStorage(storage: SettingsStorage, scope: SettingsScope, projectTrusted = true): Settings {
474 if (scope === "project" && !projectTrusted) {
475 return {};
476 }
477
478 let content: string | undefined;
479 storage.withLock(scope, (current) => {
480 content = current;
481 return undefined;
482 });
483
484 if (!content) {
485 return {};
486 }
487 const settings = JSON.parse(stripBom(content));
488 return SettingsManager.migrateSettings(settings);
489 }
490
491 private static tryLoadFromStorage(
492 storage: SettingsStorage,
493 scope: SettingsScope,
494 projectTrusted = true,
495 ): { settings: Settings; error: Error | null } {
496 try {
497 return { settings: SettingsManager.loadFromStorage(storage, scope, projectTrusted), error: null };
498 } catch (error) {
499 return { settings: {}, error: error as Error };
500 }
501 }
502
503 /** Migrate old settings format to new format */
504 private static migrateSettings(settings: Record<string, unknown>): Settings {
505 // Migrate queueMode -> steeringMode
506 if ("queueMode" in settings && !("steeringMode" in settings)) {
507 settings.steeringMode = settings.queueMode;
508 delete settings.queueMode;
509 }
510
511 // Migrate legacy websockets boolean -> transport enum
512 if (!("transport" in settings) && typeof settings.websockets === "boolean") {
513 settings.transport = settings.websockets ? "websocket" : "sse";
514 delete settings.websockets;
515 }
516
517 // Migrate old skills object format to new array format
518 if (
519 "skills" in settings &&
520 typeof settings.skills === "object" &&
521 settings.skills !== null &&
522 !Array.isArray(settings.skills)
523 ) {
524 const skillsSettings = settings.skills as {
525 enableSkillCommands?: boolean;
526 customDirectories?: unknown;
527 };
528 if (skillsSettings.enableSkillCommands !== undefined && settings.enableSkillCommands === undefined) {
529 settings.enableSkillCommands = skillsSettings.enableSkillCommands;
530 }
531 if (Array.isArray(skillsSettings.customDirectories) && skillsSettings.customDirectories.length > 0) {
532 settings.skills = skillsSettings.customDirectories;
533 } else {
534 delete settings.skills;
535 }
536 }
537
538 // Migrate retry.maxDelayMs -> retry.provider.maxRetryDelayMs
539 if (
540 "retry" in settings &&
541 typeof settings.retry === "object" &&
542 settings.retry !== null &&
543 !Array.isArray(settings.retry)
544 ) {
545 const retrySettings = settings.retry as Record<string, unknown>;
546 const providerSettings =
547 typeof retrySettings.provider === "object" && retrySettings.provider !== null
548 ? (retrySettings.provider as Record<string, unknown>)
549 : undefined;
550 if (
551 typeof retrySettings.maxDelayMs === "number" &&
552 (providerSettings?.maxRetryDelayMs === undefined || providerSettings?.maxRetryDelayMs === null)
553 ) {
554 retrySettings.provider = {
555 ...(providerSettings ?? {}),
556 maxRetryDelayMs: retrySettings.maxDelayMs,
557 };
558 }
559 delete retrySettings.maxDelayMs;
560 }
561
562 return settings as Settings;
563 }
564
565 /** A copy of the effective settings: global and project settings merged, with overrides. */
566 getSettings(): Settings {
567 return structuredClone(this.settings);
568 }
569
570 getGlobalSettings(): Settings {
571 return structuredClone(this.globalSettings);
572 }
573
574 getProjectSettings(): Settings {
575 return structuredClone(this.projectSettings);
576 }
577
578 isProjectTrusted(): boolean {
579 return this.projectTrusted;
580 }
581
582 setProjectTrusted(trusted: boolean): void {
583 if (this.projectTrusted === trusted) {
584 return;
585 }
586
587 this.projectTrusted = trusted;
588 this.modifiedProjectFields.clear();
589 this.modifiedProjectNestedFields.clear();
590
591 if (!trusted) {
592 this.projectSettings = {};
593 this.projectSettingsLoadError = null;
594 this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
595 return;
596 }
597
598 const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project", trusted);
599 this.projectSettings = projectLoad.settings;
600 this.projectSettingsLoadError = projectLoad.error;
601 if (projectLoad.error) {
602 this.recordError("project", projectLoad.error);
603 }
604 this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
605 }
606
607 async reload(): Promise<void> {
608 await this.writeQueue;
609 const globalLoad = SettingsManager.tryLoadFromStorage(this.storage, "global");
610 if (!globalLoad.error) {
611 this.globalSettings = globalLoad.settings;
612 this.globalSettingsLoadError = null;
613 } else {
614 this.globalSettingsLoadError = globalLoad.error;
615 this.recordError("global", globalLoad.error);
616 }
617
618 this.modifiedFields.clear();
619 this.modifiedNestedFields.clear();
620 this.modifiedProjectFields.clear();
621 this.modifiedProjectNestedFields.clear();
622
623 const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project", this.projectTrusted);
624 if (!projectLoad.error) {
625 this.projectSettings = projectLoad.settings;
626 this.projectSettingsLoadError = null;
627 } else {
628 this.projectSettingsLoadError = projectLoad.error;
629 this.recordError("project", projectLoad.error);
630 }
631
632 this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
633 }
634
635 /** Apply additional overrides on top of current settings */
636 applyOverrides(overrides: Partial<Settings>): void {
637 this.settings = deepMergeSettings(this.settings, overrides);
638 }
639
640 /** Mark a global field as modified during this session */
641 private markModified(field: keyof Settings, nestedKey?: string): void {
642 this.modifiedFields.add(field);
643 if (nestedKey) {
644 if (!this.modifiedNestedFields.has(field)) {
645 this.modifiedNestedFields.set(field, new Set());
646 }
647 this.modifiedNestedFields.get(field)!.add(nestedKey);
648 }
649 }
650
651 /** Mark a project field as modified during this session */
652 private markProjectModified(field: keyof Settings, nestedKey?: string): void {
653 this.modifiedProjectFields.add(field);
654 if (nestedKey) {
655 if (!this.modifiedProjectNestedFields.has(field)) {
656 this.modifiedProjectNestedFields.set(field, new Set());
657 }
658 this.modifiedProjectNestedFields.get(field)!.add(nestedKey);
659 }
660 }
661
662 private assertProjectTrustedForWrite(): void {
663 if (!this.projectTrusted) {
664 throw new Error("Project is not trusted; refusing to write project settings");
665 }
666 }
667
668 private recordError(scope: SettingsScope, error: unknown): void {
669 this.errors.push(toSettingsError(scope, error, this.settingsPaths[scope]));
670 }
671
672 private clearModifiedScope(scope: SettingsScope): void {
673 if (scope === "global") {
674 this.modifiedFields.clear();
675 this.modifiedNestedFields.clear();
676 return;
677 }
678
679 this.modifiedProjectFields.clear();
680 this.modifiedProjectNestedFields.clear();
681 }
682
683 private enqueueWrite(scope: SettingsScope, task: () => void): void {
684 this.writeQueue = this.writeQueue
685 .then(() => {
686 if (scope === "project") {
687 this.assertProjectTrustedForWrite();
688 }
689 task();
690 this.clearModifiedScope(scope);
691 })
692 .catch((error) => {
693 this.recordError(scope, error);
694 });
695 }
696
697 private cloneModifiedNestedFields(source: Map<keyof Settings, Set<string>>): Map<keyof Settings, Set<string>> {
698 const snapshot = new Map<keyof Settings, Set<string>>();
699 for (const [key, value] of source.entries()) {
700 snapshot.set(key, new Set(value));
701 }
702 return snapshot;
703 }
704
705 private persistScopedSettings(
706 scope: SettingsScope,
707 snapshotSettings: Settings,
708 modifiedFields: Set<keyof Settings>,
709 modifiedNestedFields: Map<keyof Settings, Set<string>>,
710 ): void {
711 this.storage.withLock(scope, (current) => {
712 const currentFileSettings = current
713 ? SettingsManager.migrateSettings(JSON.parse(stripBom(current)) as Record<string, unknown>)
714 : {};
715 const mergedSettings: Settings = { ...currentFileSettings };
716 for (const field of modifiedFields) {
717 const value = snapshotSettings[field];
718 if (modifiedNestedFields.has(field) && typeof value === "object" && value !== null) {
719 const nestedModified = modifiedNestedFields.get(field)!;
720 const baseNested = (currentFileSettings[field] as Record<string, unknown>) ?? {};
721 const inMemoryNested = value as Record<string, unknown>;
722 const mergedNested = { ...baseNested };
723 for (const nestedKey of nestedModified) {
724 mergedNested[nestedKey] = inMemoryNested[nestedKey];
725 }
726 (mergedSettings as Record<string, unknown>)[field] = mergedNested;
727 } else {
728 (mergedSettings as Record<string, unknown>)[field] = value;
729 }
730 }
731
732 return JSON.stringify(mergedSettings, null, 2);
733 });
734 }
735
736 private save(): void {
737 this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
738
739 if (this.globalSettingsLoadError) {
740 return;
741 }
742
743 const snapshotGlobalSettings = structuredClone(this.globalSettings);
744 const modifiedFields = new Set(this.modifiedFields);
745 const modifiedNestedFields = this.cloneModifiedNestedFields(this.modifiedNestedFields);
746
747 this.enqueueWrite("global", () => {
748 this.persistScopedSettings("global", snapshotGlobalSettings, modifiedFields, modifiedNestedFields);
749 });
750 }
751
752 private saveProjectSettings(settings: Settings): void {
753 this.assertProjectTrustedForWrite();
754 this.projectSettings = structuredClone(settings);
755 this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);
756
757 if (this.projectSettingsLoadError) {
758 return;
759 }
760
761 const snapshotProjectSettings = structuredClone(this.projectSettings);
762 const modifiedFields = new Set(this.modifiedProjectFields);
763 const modifiedNestedFields = this.cloneModifiedNestedFields(this.modifiedProjectNestedFields);
764 this.enqueueWrite("project", () => {
765 this.persistScopedSettings("project", snapshotProjectSettings, modifiedFields, modifiedNestedFields);
766 });
767 }
768
769 private updateProjectSettings(field: keyof Settings, update: (settings: Settings) => void): void {
770 this.assertProjectTrustedForWrite();
771 const projectSettings = structuredClone(this.projectSettings);
772 update(projectSettings);
773 this.markProjectModified(field);
774 this.saveProjectSettings(projectSettings);
775 }
776
777 async flush(): Promise<void> {
778 await this.writeQueue;
779 }
780
781 drainErrors(): SettingsError[] {
782 const drained = [...this.errors];
783 this.errors = [];
784 return drained;
785 }
786
787 getLastChangelogVersion(): string | undefined {
788 return this.settings.lastChangelogVersion;
789 }
790
791 setLastChangelogVersion(version: string): void {
792 this.globalSettings.lastChangelogVersion = version;
793 this.markModified("lastChangelogVersion");
794 this.save();
795 }
796
797 getSessionDir(): string | undefined {
798 const sessionDir = this.settings.sessionDir;
799 return sessionDir ? normalizePath(sessionDir) : sessionDir;
800 }
801
802 getDefaultProvider(): string | undefined {
803 return this.settings.defaultProvider;
804 }
805
806 getDefaultModel(): string | undefined {
807 return this.settings.defaultModel;
808 }
809
810 setDefaultProvider(provider: string): void {
811 this.globalSettings.defaultProvider = provider;
812 this.markModified("defaultProvider");
813 this.save();
814 }
815
816 setDefaultModel(modelId: string): void {
817 this.globalSettings.defaultModel = modelId;
818 this.markModified("defaultModel");
819 this.save();
820 }
821
822 setDefaultModelAndProvider(provider: string, modelId: string): void {
823 this.globalSettings.defaultProvider = provider;
824 this.globalSettings.defaultModel = modelId;
825 this.markModified("defaultProvider");
826 this.markModified("defaultModel");
827 this.save();
828 }
829
830 getSteeringMode(): "all" | "one-at-a-time" {
831 return this.settings.steeringMode || "one-at-a-time";
832 }
833
834 setSteeringMode(mode: "all" | "one-at-a-time"): void {
835 this.globalSettings.steeringMode = mode;
836 this.markModified("steeringMode");
837 this.save();
838 }
839
840 getFollowUpMode(): "all" | "one-at-a-time" {
841 return this.settings.followUpMode || "one-at-a-time";
842 }
843
844 setFollowUpMode(mode: "all" | "one-at-a-time"): void {
845 this.globalSettings.followUpMode = mode;
846 this.markModified("followUpMode");
847 this.save();
848 }
849
850 getThemeSetting(): string | undefined {
851 const value = this.settings.theme;
852 if (typeof value === "string") return value;
853 return undefined;
854 }
855
856 getTheme(): string | undefined {
857 const theme = this.getThemeSetting();
858 return theme?.includes("/") ? undefined : theme;
859 }
860
861 setTheme(theme: string): void {
862 this.globalSettings.theme = theme;
863 this.markModified("theme");
864 this.save();
865 }
866
867 getDefaultThinkingLevel(): ThinkingLevel | undefined {
868 return this.settings.defaultThinkingLevel;
869 }
870
871 setDefaultThinkingLevel(level: ThinkingLevel): void {
872 this.globalSettings.defaultThinkingLevel = level;
873 this.markModified("defaultThinkingLevel");
874 this.save();
875 }
876
877 getModelThinkingLevel(provider: string, modelId: string): ThinkingLevel | undefined {
878 return this.settings.modelThinkingLevels?.[`${provider}/${modelId}`];
879 }
880
881 getAllModelThinkingLevels(): Record<string, ThinkingLevel> {
882 return { ...(this.settings.modelThinkingLevels ?? {}) };
883 }
884
885 setModelThinkingLevel(provider: string, modelId: string, level: ThinkingLevel): void {
886 if (!this.globalSettings.modelThinkingLevels) {
887 this.globalSettings.modelThinkingLevels = {};
888 }
889 this.globalSettings.modelThinkingLevels[`${provider}/${modelId}`] = level;
890 this.markModified("modelThinkingLevels");
891 this.save();
892 }
893
894 removeModelThinkingLevel(provider: string, modelId: string): void {
895 if (!this.globalSettings.modelThinkingLevels) return;
896 delete this.globalSettings.modelThinkingLevels[`${provider}/${modelId}`];
897 if (Object.keys(this.globalSettings.modelThinkingLevels).length === 0) {
898 delete this.globalSettings.modelThinkingLevels;
899 }
900 this.markModified("modelThinkingLevels");
901 this.save();
902 }
903
904 getTransport(): TransportSetting {
905 return this.settings.transport ?? "auto";
906 }
907
908 setTransport(transport: TransportSetting): void {
909 this.globalSettings.transport = transport;
910 this.markModified("transport");
911 this.save();
912 }
913
914 getCompactionEnabled(): boolean {
915 return this.settings.compaction?.enabled ?? true;
916 }
917
918 setCompactionEnabled(enabled: boolean): void {
919 if (!this.globalSettings.compaction) {
920 this.globalSettings.compaction = {};
921 }
922 this.globalSettings.compaction.enabled = enabled;
923 this.markModified("compaction", "enabled");
924 this.save();
925 }
926
927 private getCompactionTokenSetting(
928 field: keyof CompactionModelOverride,
929 model?: Pick<Model<string>, "provider" | "id">,
930 ): number {
931 const compaction = this.settings.compaction;
932 const ordinary = compaction?.[field];
933 if (ordinary !== undefined && (typeof ordinary !== "number" || !Number.isSafeInteger(ordinary) || ordinary < 0)) {
934 throw new Error(
935 `Invalid compaction.${field} setting: ${String(ordinary)}. Expected a non-negative safe integer.`,
936 );
937 }
938
939 const modelKey = model ? `${model.provider}/${model.id}` : undefined;
940 const entry = modelKey !== undefined ? compaction?.modelOverrides?.[modelKey] : undefined;
941 if (entry !== undefined && !isMergeableObject(entry)) {
942 throw new Error(
943 `Invalid compaction.modelOverrides["${modelKey}"] setting: ${String(entry)}. Expected an object.`,
944 );
945 }
946 const override = entry?.[field];
947 if (override !== undefined && (typeof override !== "number" || !Number.isSafeInteger(override) || override < 0)) {
948 throw new Error(
949 `Invalid compaction.modelOverrides["${modelKey}"].${field} setting: ${String(override)}. Expected a non-negative safe integer.`,
950 );
951 }
952 return override ?? ordinary ?? DEFAULT_COMPACTION_TOKEN_SETTINGS[field];
953 }
954
955 getCompactionReserveTokens(model?: Pick<Model<string>, "provider" | "id">): number {
956 return this.getCompactionTokenSetting("reserveTokens", model);
957 }
958
959 getCompactionKeepRecentTokens(model?: Pick<Model<string>, "provider" | "id">): number {
960 return this.getCompactionTokenSetting("keepRecentTokens", model);
961 }
962
963 /** Resolve each token setting through model override, ordinary setting, then built-in default. */
964 getCompactionSettings(model?: Pick<Model<string>, "provider" | "id">): {
965 enabled: boolean;
966 reserveTokens: number;
967 keepRecentTokens: number;
968 } {
969 return {
970 enabled: this.getCompactionEnabled(),
971 reserveTokens: this.getCompactionReserveTokens(model),
972 keepRecentTokens: this.getCompactionKeepRecentTokens(model),
973 };
974 }
975
976 getBranchSummarySettings(): { reserveTokens: number; skipPrompt: boolean } {
977 return {
978 reserveTokens: this.settings.branchSummary?.reserveTokens ?? 16384,
979 skipPrompt: this.settings.branchSummary?.skipPrompt ?? false,
980 };
981 }
982
983 getBranchSummarySkipPrompt(): boolean {
984 return this.settings.branchSummary?.skipPrompt ?? false;
985 }
986
987 getRetryEnabled(): boolean {
988 return this.settings.retry?.enabled ?? true;
989 }
990
991 setRetryEnabled(enabled: boolean): void {
992 if (!this.globalSettings.retry) {
993 this.globalSettings.retry = {};
994 }
995 this.globalSettings.retry.enabled = enabled;
996 this.markModified("retry", "enabled");
997 this.save();
998 }
999
1000 getRetrySettings(): { enabled: boolean; maxRetries: number; baseDelayMs: number; maxAgentDelayMs: number } {
1001 return {
1002 enabled: this.getRetryEnabled(),
1003 maxRetries: this.settings.retry?.maxRetries ?? 3,
1004 baseDelayMs: this.settings.retry?.baseDelayMs ?? 2000,
1005 maxAgentDelayMs: this.settings.retry?.maxAgentDelayMs ?? DEFAULT_MAX_AGENT_RETRY_DELAY_MS,
1006 };
1007 }
1008
1009 getHttpIdleTimeoutMs(): number {
1010 return parseTimeoutSetting(this.settings.httpIdleTimeoutMs, "httpIdleTimeoutMs") ?? DEFAULT_HTTP_IDLE_TIMEOUT_MS;
1011 }
1012
1013 setHttpIdleTimeoutMs(timeoutMs: number): void {
1014 if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
1015 throw new Error(`Invalid httpIdleTimeoutMs setting: ${String(timeoutMs)}`);
1016 }
1017 this.globalSettings.httpIdleTimeoutMs = Math.floor(timeoutMs);
1018 this.markModified("httpIdleTimeoutMs");
1019 this.save();
1020 }
1021
1022 /** Read from global settings only because warming costs money. */
1023 getCacheWarmingMode(): CacheWarmingMode {
1024 const mode = this.globalSettings.cacheWarming;
1025 return mode !== undefined && CACHE_WARMING_MODES.includes(mode) ? mode : "streaming";
1026 }
1027
1028 setCacheWarmingMode(mode: CacheWarmingMode): void {
1029 this.globalSettings.cacheWarming = mode;
1030 this.markModified("cacheWarming");
1031 this.save();
1032 }
1033
1034 getProviderRetrySettings(): { timeoutMs?: number; maxRetries?: number; maxRetryDelayMs: number } {
1035 return {
1036 timeoutMs: this.settings.retry?.provider?.timeoutMs,
1037 maxRetries: this.settings.retry?.provider?.maxRetries,
1038 maxRetryDelayMs: this.settings.retry?.provider?.maxRetryDelayMs ?? 60000,
1039 };
1040 }
1041
1042 getWebSocketConnectTimeoutMs(): number | undefined {
1043 return parseTimeoutSetting(this.settings.websocketConnectTimeoutMs, "websocketConnectTimeoutMs");
1044 }
1045
1046 getHideThinkingBlock(): boolean {
1047 return this.settings.hideThinkingBlock ?? false;
1048 }
1049
1050 getShowCacheMissNotices(): boolean {
1051 return this.settings.showCacheMissNotices ?? false;
1052 }
1053
1054 getExternalEditorCommand(): string {
1055 const configuredEditor = this.settings.externalEditor;
1056 if (typeof configuredEditor === "string" && configuredEditor.trim() !== "") {
1057 return configuredEditor;
1058 }
1059 const environmentEditor = process.env.VISUAL || process.env.EDITOR;
1060 if (environmentEditor) {
1061 return environmentEditor;
1062 }
1063 return process.platform === "win32" ? "notepad" : "nano";
1064 }
1065
1066 setHideThinkingBlock(hide: boolean): void {
1067 this.globalSettings.hideThinkingBlock = hide;
1068 this.markModified("hideThinkingBlock");
1069 this.save();
1070 }
1071
1072 setShowCacheMissNotices(show: boolean): void {
1073 this.globalSettings.showCacheMissNotices = show;
1074 this.markModified("showCacheMissNotices");
1075 this.save();
1076 }
1077
1078 getShellPath(): string | undefined {
1079 const shellPath = this.settings.shellPath;
1080 return shellPath ? normalizePath(shellPath) : shellPath;
1081 }
1082
1083 setShellPath(path: string | undefined): void {
1084 this.globalSettings.shellPath = path;
1085 this.markModified("shellPath");
1086 this.save();
1087 }
1088
1089 getQuietStartup(): QuietStartup {
1090 const value = this.settings.quietStartup;
1091 return value === true || value === "header" ? value : false;
1092 }
1093
1094 setQuietStartup(quiet: QuietStartup): void {
1095 this.globalSettings.quietStartup = quiet;
1096 this.markModified("quietStartup");
1097 this.save();
1098 }
1099
1100 getDefaultProjectTrust(): DefaultProjectTrust {
1101 const value = this.globalSettings.defaultProjectTrust;
1102 return value === "always" || value === "never" ? value : "ask";
1103 }
1104
1105 setDefaultProjectTrust(defaultProjectTrust: DefaultProjectTrust): void {
1106 this.globalSettings.defaultProjectTrust = defaultProjectTrust;
1107 this.markModified("defaultProjectTrust");
1108 this.save();
1109 }
1110
1111 getShellCommandPrefix(): string | undefined {
1112 return this.settings.shellCommandPrefix;
1113 }
1114
1115 setShellCommandPrefix(prefix: string | undefined): void {
1116 this.globalSettings.shellCommandPrefix = prefix;
1117 this.markModified("shellCommandPrefix");
1118 this.save();
1119 }
1120
1121 getNpmCommand(): string[] | undefined {
1122 return this.settings.npmCommand ? [...this.settings.npmCommand] : undefined;
1123 }
1124
1125 setNpmCommand(command: string[] | undefined): void {
1126 this.globalSettings.npmCommand = command ? [...command] : undefined;
1127 this.markModified("npmCommand");
1128 this.save();
1129 }
1130
1131 getCollapseChangelog(): boolean {
1132 return this.settings.collapseChangelog ?? false;
1133 }
1134
1135 setCollapseChangelog(collapse: boolean): void {
1136 this.globalSettings.collapseChangelog = collapse;
1137 this.markModified("collapseChangelog");
1138 this.save();
1139 }
1140
1141 getEnableInstallTelemetry(): boolean {
1142 return this.settings.enableInstallTelemetry ?? true;
1143 }
1144
1145 setEnableInstallTelemetry(enabled: boolean): void {
1146 this.globalSettings.enableInstallTelemetry = enabled;
1147 this.markModified("enableInstallTelemetry");
1148 this.save();
1149 }
1150
1151 getEnableAnalytics(): boolean {
1152 return this.settings.enableAnalytics ?? false;
1153 }
1154
1155 getTrackingId(): string | undefined {
1156 return this.settings.trackingId;
1157 }
1158
1159 /** Set the analytics opt-in preference; generates a tracking identifier on first opt-in */
1160 setEnableAnalytics(enabled: boolean): void {
1161 this.globalSettings.enableAnalytics = enabled;
1162 this.markModified("enableAnalytics");
1163 if (enabled && !this.globalSettings.trackingId) {
1164 this.globalSettings.trackingId = randomUUID();
1165 this.markModified("trackingId");
1166 }
1167 this.save();
1168 }
1169
1170 /**
1171 * Stable ID of this installation, e.g. sent to OpenAI as its agent host ID.
1172 * Created on first use. Project settings are ignored so a committed project
1173 * settings file cannot give every clone the same ID.
1174 */
1175 getOrCreateDeviceId(): string {
1176 if (!this.globalSettings.deviceId) {
1177 this.globalSettings.deviceId = randomUUID();
1178 this.markModified("deviceId");
1179 this.save();
1180 }
1181 return this.globalSettings.deviceId;
1182 }
1183
1184 getPackages(): PackageSource[] {
1185 return [...(this.settings.packages ?? [])];
1186 }
1187
1188 setPackages(packages: PackageSource[]): void {
1189 this.globalSettings.packages = packages;
1190 this.markModified("packages");
1191 this.save();
1192 }
1193
1194 setProjectPackages(packages: PackageSource[]): void {
1195 this.updateProjectSettings("packages", (settings) => {
1196 settings.packages = packages;
1197 });
1198 }
1199
1200 getExtensionPaths(): string[] {
1201 return [...(this.settings.extensions ?? [])];
1202 }
1203
1204 setExtensionPaths(paths: string[]): void {
1205 this.globalSettings.extensions = paths;
1206 this.markModified("extensions");
1207 this.save();
1208 }
1209
1210 setProjectExtensionPaths(paths: string[]): void {
1211 this.updateProjectSettings("extensions", (settings) => {
1212 settings.extensions = paths;
1213 });
1214 }
1215
1216 getSkillPaths(): string[] {
1217 return [...(this.settings.skills ?? [])];
1218 }
1219
1220 setSkillPaths(paths: string[]): void {
1221 this.globalSettings.skills = paths;
1222 this.markModified("skills");
1223 this.save();
1224 }
1225
1226 setProjectSkillPaths(paths: string[]): void {
1227 this.updateProjectSettings("skills", (settings) => {
1228 settings.skills = paths;
1229 });
1230 }
1231
1232 getPromptTemplatePaths(): string[] {
1233 return [...(this.settings.prompts ?? [])];
1234 }
1235
1236 setPromptTemplatePaths(paths: string[]): void {
1237 this.globalSettings.prompts = paths;
1238 this.markModified("prompts");
1239 this.save();
1240 }
1241
1242 setProjectPromptTemplatePaths(paths: string[]): void {
1243 this.updateProjectSettings("prompts", (settings) => {
1244 settings.prompts = paths;
1245 });
1246 }
1247
1248 getThemePaths(): string[] {
1249 return [...(this.settings.themes ?? [])];
1250 }
1251
1252 setThemePaths(paths: string[]): void {
1253 this.globalSettings.themes = paths;
1254 this.markModified("themes");
1255 this.save();
1256 }
1257
1258 setProjectThemePaths(paths: string[]): void {
1259 this.updateProjectSettings("themes", (settings) => {
1260 settings.themes = paths;
1261 });
1262 }
1263
1264 getEnableSkillCommands(): boolean {
1265 return this.settings.enableSkillCommands ?? true;
1266 }
1267
1268 setEnableSkillCommands(enabled: boolean): void {
1269 this.globalSettings.enableSkillCommands = enabled;
1270 this.markModified("enableSkillCommands");
1271 this.save();
1272 }
1273
1274 getThinkingBudgets(): ThinkingBudgetsSettings | undefined {
1275 return this.settings.thinkingBudgets;
1276 }
1277
1278 getTerminalCapabilityOverrides(): Partial<TerminalCapabilities> {
1279 const terminal = this.settings.terminal;
1280 const images = terminal?.images;
1281 return {
1282 ...(images === "kitty" || images === "iterm2" ? { images } : images === false ? { images: null } : {}),
1283 ...(typeof terminal?.trueColor === "boolean" ? { trueColor: terminal.trueColor } : {}),
1284 ...(typeof terminal?.hyperlinks === "boolean" ? { hyperlinks: terminal.hyperlinks } : {}),
1285 };
1286 }
1287
1288 getShowImages(): boolean {
1289 return this.settings.terminal?.showImages ?? true;
1290 }
1291
1292 setShowImages(show: boolean): void {
1293 if (!this.globalSettings.terminal) {
1294 this.globalSettings.terminal = {};
1295 }
1296 this.globalSettings.terminal.showImages = show;
1297 this.markModified("terminal", "showImages");
1298 this.save();
1299 }
1300
1301 getImageWidthCells(): number {
1302 const width = this.settings.terminal?.imageWidthCells;
1303 if (typeof width !== "number" || !Number.isFinite(width)) {
1304 return 60;
1305 }
1306 return Math.max(1, Math.floor(width));
1307 }
1308
1309 setImageWidthCells(width: number): void {
1310 if (!this.globalSettings.terminal) {
1311 this.globalSettings.terminal = {};
1312 }
1313 this.globalSettings.terminal.imageWidthCells = Math.max(1, Math.floor(width));
1314 this.markModified("terminal", "imageWidthCells");
1315 this.save();
1316 }
1317
1318 getClearOnShrink(): boolean {
1319 // Settings takes precedence, then env var, then default false
1320 if (this.settings.terminal?.clearOnShrink !== undefined) {
1321 return this.settings.terminal.clearOnShrink;
1322 }
1323 return process.env.PI_CLEAR_ON_SHRINK === "1";
1324 }
1325
1326 setClearOnShrink(enabled: boolean): void {
1327 if (!this.globalSettings.terminal) {
1328 this.globalSettings.terminal = {};
1329 }
1330 this.globalSettings.terminal.clearOnShrink = enabled;
1331 this.markModified("terminal", "clearOnShrink");
1332 this.save();
1333 }
1334
1335 getShowTerminalProgress(): boolean {
1336 return this.settings.terminal?.showTerminalProgress ?? false;
1337 }
1338
1339 setShowTerminalProgress(enabled: boolean): void {
1340 if (!this.globalSettings.terminal) {
1341 this.globalSettings.terminal = {};
1342 }
1343 this.globalSettings.terminal.showTerminalProgress = enabled;
1344 this.markModified("terminal", "showTerminalProgress");
1345 this.save();
1346 }
1347
1348 getTuiMode(): TuiMode {
1349 return this.settings.tuiMode === "regular" ? "regular" : "fullscreen";
1350 }
1351
1352 setTuiMode(mode: TuiMode): void {
1353 this.globalSettings.tuiMode = mode;
1354 this.markModified("tuiMode");
1355 this.save();
1356 }
1357
1358 getFullscreenExitOutput(): FullscreenExitOutput {
1359 return this.settings.fullscreenExitOutput === "resume-hint" ? "resume-hint" : "transcript";
1360 }
1361
1362 setFullscreenExitOutput(output: FullscreenExitOutput): void {
1363 this.globalSettings.fullscreenExitOutput = output;
1364 this.markModified("fullscreenExitOutput");
1365 this.save();
1366 }
1367
1368 getFullscreenScrollbar(): ScrollViewScrollbar {
1369 const mode = this.settings.fullscreenScrollbar;
1370 return mode === "always" || mode === "hidden" ? mode : "auto";
1371 }
1372
1373 setFullscreenScrollbar(mode: ScrollViewScrollbar): void {
1374 this.globalSettings.fullscreenScrollbar = mode;
1375 this.markModified("fullscreenScrollbar");
1376 this.save();
1377 }
1378
1379 getFullscreenCopyOnSelect(): boolean {
1380 return this.settings.fullscreenCopyOnSelect ?? true;
1381 }
1382
1383 setFullscreenCopyOnSelect(enabled: boolean): void {
1384 this.globalSettings.fullscreenCopyOnSelect = enabled;
1385 this.markModified("fullscreenCopyOnSelect");
1386 this.save();
1387 }
1388
1389 getFullscreenWheelScrollLines(): WheelScrollLines {
1390 const lines = this.settings.fullscreenWheelScrollLines;
1391 return typeof lines === "number" && Number.isFinite(lines)
1392 ? Math.max(1, Math.min(100, Math.floor(lines)))
1393 : "auto";
1394 }
1395
1396 setFullscreenWheelScrollLines(lines: WheelScrollLines): void {
1397 this.globalSettings.fullscreenWheelScrollLines =
1398 lines === "auto" ? lines : Math.max(1, Math.min(100, Math.floor(lines)));
1399 this.markModified("fullscreenWheelScrollLines");
1400 this.save();
1401 }
1402
1403 getImageAutoResize(): boolean {
1404 return this.settings.images?.autoResize ?? true;
1405 }
1406
1407 setImageAutoResize(enabled: boolean): void {
1408 if (!this.globalSettings.images) {
1409 this.globalSettings.images = {};
1410 }
1411 this.globalSettings.images.autoResize = enabled;
1412 this.markModified("images", "autoResize");
1413 this.save();
1414 }
1415
1416 getBlockImages(): boolean {
1417 return this.settings.images?.blockImages ?? false;
1418 }
1419
1420 setBlockImages(blocked: boolean): void {
1421 if (!this.globalSettings.images) {
1422 this.globalSettings.images = {};
1423 }
1424 this.globalSettings.images.blockImages = blocked;
1425 this.markModified("images", "blockImages");
1426 this.save();
1427 }
1428
1429 getEnabledModels(): string[] | undefined {
1430 return this.settings.enabledModels;
1431 }
1432
1433 /** The resolved `defaultTools` selection, or undefined when no settings layer sets it. */
1434 getDefaultTools(): string[] | undefined {
1435 const tools = this.settings.defaultTools;
1436 if (tools === undefined) return undefined;
1437 return resolveDefaultTools(Array.isArray(tools) ? tools.filter((tool) => typeof tool === "string") : []);
1438 }
1439
1440 setEnabledModels(patterns: string[] | undefined): void {
1441 this.globalSettings.enabledModels = patterns;
1442 this.markModified("enabledModels");
1443 this.save();
1444 }
1445
1446 getDoubleEscapeAction(): "fork" | "tree" | "none" {
1447 return this.settings.doubleEscapeAction ?? "tree";
1448 }
1449
1450 setDoubleEscapeAction(action: "fork" | "tree" | "none"): void {
1451 this.globalSettings.doubleEscapeAction = action;
1452 this.markModified("doubleEscapeAction");
1453 this.save();
1454 }
1455
1456 getTreeFilterMode(): "default" | "no-tools" | "user-only" | "labeled-only" | "all" {
1457 const mode = this.settings.treeFilterMode;
1458 const valid = ["default", "no-tools", "user-only", "labeled-only", "all"];
1459 return mode && valid.includes(mode) ? mode : "default";
1460 }
1461
1462 setTreeFilterMode(mode: "default" | "no-tools" | "user-only" | "labeled-only" | "all"): void {
1463 this.globalSettings.treeFilterMode = mode;
1464 this.markModified("treeFilterMode");
1465 this.save();
1466 }
1467
1468 getShowHardwareCursor(): boolean {
1469 return this.settings.showHardwareCursor ?? process.env.PI_HARDWARE_CURSOR === "1";
1470 }
1471
1472 setShowHardwareCursor(enabled: boolean): void {
1473 this.globalSettings.showHardwareCursor = enabled;
1474 this.markModified("showHardwareCursor");
1475 this.save();
1476 }
1477
1478 getEditorPaddingX(): number {
1479 return this.settings.editorPaddingX ?? 0;
1480 }
1481
1482 setEditorPaddingX(padding: number): void {
1483 this.globalSettings.editorPaddingX = Math.max(0, Math.min(3, Math.floor(padding)));
1484 this.markModified("editorPaddingX");
1485 this.save();
1486 }
1487
1488 getOutputPad(): 0 | 1 {
1489 return this.settings.outputPad === 0 ? 0 : 1;
1490 }
1491
1492 setOutputPad(padding: 0 | 1): void {
1493 this.globalSettings.outputPad = padding;
1494 this.markModified("outputPad");
1495 this.save();
1496 }
1497
1498 getAutocompleteMaxVisible(): number {
1499 return this.settings.autocompleteMaxVisible ?? 5;
1500 }
1501
1502 setAutocompleteMaxVisible(maxVisible: number): void {
1503 this.globalSettings.autocompleteMaxVisible = Math.max(3, Math.min(20, Math.floor(maxVisible)));
1504 this.markModified("autocompleteMaxVisible");
1505 this.save();
1506 }
1507
1508 getCodeBlockIndent(): string {
1509 return this.settings.markdown?.codeBlockIndent ?? " ";
1510 }
1511
1512 getMermaidRenderingMode(): MermaidRenderingMode {
1513 const mode = this.settings.markdown?.mermaid;
1514 return mode === "off" || mode === "final" ? mode : "streaming";
1515 }
1516
1517 setMermaidRenderingMode(mode: MermaidRenderingMode): void {
1518 this.globalSettings.markdown ??= {};
1519 this.globalSettings.markdown.mermaid = mode;
1520 this.markModified("markdown", "mermaid");
1521 this.save();
1522 }
1523
1524 getWarnings(): WarningSettings {
1525 return { ...(this.settings.warnings ?? {}) };
1526 }
1527
1528 setWarnings(warnings: WarningSettings): void {
1529 this.globalSettings.warnings = { ...warnings };
1530 this.markModified("warnings");
1531 this.save();
1532 }
1533}