1
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";2
import { DEFAULT_MAX_AGENT_RETRY_DELAY_MS, type Model, type Transport } from "@earendil-works/pi-ai";3
import type {4
TuiMode as RendererTuiMode,5
ScrollViewScrollbar,6
TerminalCapabilities,7
WheelScrollLines,8
} from "@earendil-works/pi-tui";9
import { randomUUID } from "crypto";10
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";11
import { dirname, join } from "path";12
import lockfile from "proper-lockfile";13
import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts";14
import { normalizePath, resolvePath } from "../utils/paths.ts";15
import { stripBom } from "../utils/text.ts";16
import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts";18
export interface CompactionModelOverride {19
reserveTokens?: number;20
keepRecentTokens?: number;21
}23
const DEFAULT_COMPACTION_TOKEN_SETTINGS: Required<CompactionModelOverride> = {24
reserveTokens: 16384,25
keepRecentTokens: 20000,26
};28
export interface CompactionSettings {29
enabled?: boolean; // default: true30
reserveTokens?: number; // default: 1638431
keepRecentTokens?: number; // default: 2000032
modelOverrides?: Record<string, CompactionModelOverride>; // exact "provider/modelId" keys33
}35
export 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 summary38
}40
export interface ProviderRetrySettings {41
timeoutMs?: number; // SDK/provider request timeout in milliseconds42
maxRetries?: number; // SDK/provider retry attempts43
maxRetryDelayMs?: number; // default: 60000 (max server-requested delay before failing)44
}46
export interface RetrySettings {47
enabled?: boolean; // default: true48
maxRetries?: number; // default: 349
baseDelayMs?: number; // default: 2000 (exponential backoff: 2s, 4s, 8s)50
maxAgentDelayMs?: number; // default: 6000051
provider?: ProviderRetrySettings;52
}54
export type TuiMode = RendererTuiMode;55
export type FullscreenExitOutput = "transcript" | "resume-hint";57
export 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
}67
export 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 providers70
}72
export interface ThinkingBudgetsSettings {73
minimal?: number;74
low?: number;75
medium?: number;76
high?: number;77
}79
export type MermaidRenderingMode = "off" | "final" | "streaming";81
/** Cache-warming profile. "idle" also warms between agent runs. */82
export const CACHE_WARMING_MODES = ["off", "streaming", "idle"] as const;83
export type CacheWarmingMode = (typeof CACHE_WARMING_MODES)[number];85
export interface MarkdownSettings {86
codeBlockIndent?: string; // default: " "87
mermaid?: MermaidRenderingMode; // default: "streaming"88
}90
export interface WarningSettings {91
anthropicExtraUsage?: boolean; // default: true92
}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 to97
* 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 are99
* not declared to the model.100
*/101
export type CodemodeMode = "on" | "only";103
export 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
}110
export type DefaultProjectTrust = "ask" | "always" | "never";111
/** true hides all startup output, "header" keeps only the startup header. */112
export type QuietStartup = boolean | "header";114
export type TransportSetting = Transport;116
/**117
* Package source for npm/git packages.118
* - String form: load all resources from the package119
* - Object form: filter which resources to load120
* - autoload=false: start empty and only apply explicit resource patterns121
*/122
export type PackageSource =123
| string124
| {125
source: string;126
autoload?: boolean;127
extensions?: string[];128
skills?: string[];129
prompts?: string[];130
themes?: string[];131
};133
export 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 notices148
externalEditor?: string; // Command for Ctrl+G external editor; takes precedence over VISUAL/EDITOR149
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows); supports leading ~ expansion150
quietStartup?: QuietStartup; // default: false151
defaultProjectTrust?: DefaultProjectTrust; // default: "ask"; global setting only152
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 updates156
enableAnalytics?: boolean; // default: false - opt-in analytics data sharing157
trackingId?: string; // analytics tracking identifier, generated when analytics is enabled158
deviceId?: string; // stable UUID of this installation, created when a login first needs it; global setting only159
packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering)160
extensions?: string[]; // Array of local extension file paths or directories161
skills?: string[]; // Array of local skill file paths or directories162
prompts?: string[]; // Array of local prompt template paths or directories163
themes?: string[]; // Array of local theme file paths or directories164
enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands165
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 selection169
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 /tree171
thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels172
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 IME176
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 clients181
httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it182
cacheWarming?: CacheWarmingMode; // default: "streaming"; global only because each refresh costs money183
websocketConnectTimeoutMs?: number; // WebSocket connect/open handshake timeout in milliseconds; 0 disables it184
tuiMode?: TuiMode; // default: "fullscreen"185
fullscreenExitOutput?: FullscreenExitOutput; // default: "transcript"; no effect in regular TUI mode186
fullscreenScrollbar?: ScrollViewScrollbar; // default: "auto"; no effect in regular TUI mode187
fullscreenCopyOnSelect?: boolean; // default: true; no effect in regular TUI mode188
fullscreenWheelScrollLines?: WheelScrollLines; // default: "auto"; lines per wheel event, 1-100189
}191
function isMergeableObject(value: unknown): value is Record<string, unknown> {192
return typeof value === "object" && value !== null && !Array.isArray(value);193
}195
function deepMergeObjects(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, unknown> {196
const result = { ...base };198
for (const key of Object.keys(overrides)) {199
const overrideValue = overrides[key];200
if (overrideValue === undefined) {201
continue;202
}204
const baseValue = base[key];205
result[key] =206
isMergeableObject(baseValue) && isMergeableObject(overrideValue)207
? deepMergeObjects(baseValue, overrideValue)208
: overrideValue;209
}211
return result;212
}214
/** Tools enabled at startup when `defaultTools` does not change them. */215
export const DEFAULT_TOOL_NAMES: readonly string[] = ["read", "bash", "edit", "write"];217
function isToolModifier(entry: unknown): boolean {218
return typeof entry === "string" && (entry.startsWith("+") || entry.startsWith("-"));219
}221
/**222
* Merge `defaultTools` of two settings layers. A list with plain tool names replaces the inherited223
* one; a list of only `+name`/`-name` entries is appended, so it modifies the inherited selection.224
*/225
function 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
}232
/**233
* Resolve a merged `defaultTools` list: plain names replace `DEFAULT_TOOL_NAMES`, then `+name` adds234
* and `-name` removes a tool, in list order.235
*/236
function 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
}249
/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */250
function 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
}256
function 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
}267
export type SettingsScope = "global" | "project";269
export interface SettingsManagerCreateOptions {270
projectTrusted?: boolean;271
}273
export interface SettingsStorage {274
withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void;275
}277
export interface SettingsError {278
scope: SettingsScope;279
path?: string;280
error: Error;281
}283
type SettingsPaths = Partial<Record<SettingsScope, string>>;285
function 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
}293
export class FileSettingsStorage implements SettingsStorage {294
private globalSettingsPath: string;295
private projectSettingsPath: string;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
}304
private acquireLockSyncWithRetry(path: string): () => void {305
const maxAttempts = 10;306
const delayMs = 20;307
let lastError: unknown;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 error315
? 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
}328
throw (lastError as Error) ?? new Error("Failed to acquire settings lock");329
}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);335
let release: (() => void) | undefined;336
try {337
// Only create directory and lock if file exists or we need to write338
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 write346
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
}362
export class InMemorySettingsStorage implements SettingsStorage {363
private global: string | undefined;364
private project: string | undefined;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
}379
export 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 session386
private modifiedNestedFields = new Map<keyof Settings, Set<string>>(); // Track global nested field modifications387
private modifiedProjectFields = new Set<keyof Settings>(); // Track project fields modified during session388
private modifiedProjectNestedFields = new Map<keyof Settings, Set<string>>(); // Track project nested field modifications389
private globalSettingsLoadError: Error | null = null; // Track if global settings file had parse errors390
private projectSettingsLoadError: Error | null = null; // Track if project settings file had parse errors391
private writeQueue: Promise<void> = Promise.resolve();392
private errors: SettingsError[];393
private settingsPaths: SettingsPaths;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
}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
}431
/** Create a SettingsManager from an arbitrary storage backend */432
static fromStorage(storage: SettingsStorage, options: SettingsManagerCreateOptions = {}): SettingsManager {433
return SettingsManager.fromStorageWithPaths(storage, options);434
}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
}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
}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
}473
private static loadFromStorage(storage: SettingsStorage, scope: SettingsScope, projectTrusted = true): Settings {474
if (scope === "project" && !projectTrusted) {475
return {};476
}478
let content: string | undefined;479
storage.withLock(scope, (current) => {480
content = current;481
return undefined;482
});484
if (!content) {485
return {};486
}487
const settings = JSON.parse(stripBom(content));488
return SettingsManager.migrateSettings(settings);489
}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
}503
/** Migrate old settings format to new format */504
private static migrateSettings(settings: Record<string, unknown>): Settings {505
// Migrate queueMode -> steeringMode506
if ("queueMode" in settings && !("steeringMode" in settings)) {507
settings.steeringMode = settings.queueMode;508
delete settings.queueMode;509
}511
// Migrate legacy websockets boolean -> transport enum512
if (!("transport" in settings) && typeof settings.websockets === "boolean") {513
settings.transport = settings.websockets ? "websocket" : "sse";514
delete settings.websockets;515
}517
// Migrate old skills object format to new array format518
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
}538
// Migrate retry.maxDelayMs -> retry.provider.maxRetryDelayMs539
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 !== null548
? (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
}562
return settings as Settings;563
}565
/** A copy of the effective settings: global and project settings merged, with overrides. */566
getSettings(): Settings {567
return structuredClone(this.settings);568
}570
getGlobalSettings(): Settings {571
return structuredClone(this.globalSettings);572
}574
getProjectSettings(): Settings {575
return structuredClone(this.projectSettings);576
}578
isProjectTrusted(): boolean {579
return this.projectTrusted;580
}582
setProjectTrusted(trusted: boolean): void {583
if (this.projectTrusted === trusted) {584
return;585
}587
this.projectTrusted = trusted;588
this.modifiedProjectFields.clear();589
this.modifiedProjectNestedFields.clear();591
if (!trusted) {592
this.projectSettings = {};593
this.projectSettingsLoadError = null;594
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);595
return;596
}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
}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
}618
this.modifiedFields.clear();619
this.modifiedNestedFields.clear();620
this.modifiedProjectFields.clear();621
this.modifiedProjectNestedFields.clear();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
}632
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);633
}635
/** Apply additional overrides on top of current settings */636
applyOverrides(overrides: Partial<Settings>): void {637
this.settings = deepMergeSettings(this.settings, overrides);638
}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
}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
}662
private assertProjectTrustedForWrite(): void {663
if (!this.projectTrusted) {664
throw new Error("Project is not trusted; refusing to write project settings");665
}666
}668
private recordError(scope: SettingsScope, error: unknown): void {669
this.errors.push(toSettingsError(scope, error, this.settingsPaths[scope]));670
}672
private clearModifiedScope(scope: SettingsScope): void {673
if (scope === "global") {674
this.modifiedFields.clear();675
this.modifiedNestedFields.clear();676
return;677
}679
this.modifiedProjectFields.clear();680
this.modifiedProjectNestedFields.clear();681
}683
private enqueueWrite(scope: SettingsScope, task: () => void): void {684
this.writeQueue = this.writeQueue685
.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
}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
}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 = current713
? 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
}732
return JSON.stringify(mergedSettings, null, 2);733
});734
}736
private save(): void {737
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);739
if (this.globalSettingsLoadError) {740
return;741
}743
const snapshotGlobalSettings = structuredClone(this.globalSettings);744
const modifiedFields = new Set(this.modifiedFields);745
const modifiedNestedFields = this.cloneModifiedNestedFields(this.modifiedNestedFields);747
this.enqueueWrite("global", () => {748
this.persistScopedSettings("global", snapshotGlobalSettings, modifiedFields, modifiedNestedFields);749
});750
}752
private saveProjectSettings(settings: Settings): void {753
this.assertProjectTrustedForWrite();754
this.projectSettings = structuredClone(settings);755
this.settings = deepMergeSettings(this.globalSettings, this.projectSettings);757
if (this.projectSettingsLoadError) {758
return;759
}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
}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
}777
async flush(): Promise<void> {778
await this.writeQueue;779
}781
drainErrors(): SettingsError[] {782
const drained = [...this.errors];783
this.errors = [];784
return drained;785
}787
getLastChangelogVersion(): string | undefined {788
return this.settings.lastChangelogVersion;789
}791
setLastChangelogVersion(version: string): void {792
this.globalSettings.lastChangelogVersion = version;793
this.markModified("lastChangelogVersion");794
this.save();795
}797
getSessionDir(): string | undefined {798
const sessionDir = this.settings.sessionDir;799
return sessionDir ? normalizePath(sessionDir) : sessionDir;800
}802
getDefaultProvider(): string | undefined {803
return this.settings.defaultProvider;804
}806
getDefaultModel(): string | undefined {807
return this.settings.defaultModel;808
}810
setDefaultProvider(provider: string): void {811
this.globalSettings.defaultProvider = provider;812
this.markModified("defaultProvider");813
this.save();814
}816
setDefaultModel(modelId: string): void {817
this.globalSettings.defaultModel = modelId;818
this.markModified("defaultModel");819
this.save();820
}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
}830
getSteeringMode(): "all" | "one-at-a-time" {831
return this.settings.steeringMode || "one-at-a-time";832
}834
setSteeringMode(mode: "all" | "one-at-a-time"): void {835
this.globalSettings.steeringMode = mode;836
this.markModified("steeringMode");837
this.save();838
}840
getFollowUpMode(): "all" | "one-at-a-time" {841
return this.settings.followUpMode || "one-at-a-time";842
}844
setFollowUpMode(mode: "all" | "one-at-a-time"): void {845
this.globalSettings.followUpMode = mode;846
this.markModified("followUpMode");847
this.save();848
}850
getThemeSetting(): string | undefined {851
const value = this.settings.theme;852
if (typeof value === "string") return value;853
return undefined;854
}856
getTheme(): string | undefined {857
const theme = this.getThemeSetting();858
return theme?.includes("/") ? undefined : theme;859
}861
setTheme(theme: string): void {862
this.globalSettings.theme = theme;863
this.markModified("theme");864
this.save();865
}867
getDefaultThinkingLevel(): ThinkingLevel | undefined {868
return this.settings.defaultThinkingLevel;869
}871
setDefaultThinkingLevel(level: ThinkingLevel): void {872
this.globalSettings.defaultThinkingLevel = level;873
this.markModified("defaultThinkingLevel");874
this.save();875
}877
getModelThinkingLevel(provider: string, modelId: string): ThinkingLevel | undefined {878
return this.settings.modelThinkingLevels?.[`${provider}/${modelId}`];879
}881
getAllModelThinkingLevels(): Record<string, ThinkingLevel> {882
return { ...(this.settings.modelThinkingLevels ?? {}) };883
}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
}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
}904
getTransport(): TransportSetting {905
return this.settings.transport ?? "auto";906
}908
setTransport(transport: TransportSetting): void {909
this.globalSettings.transport = transport;910
this.markModified("transport");911
this.save();912
}914
getCompactionEnabled(): boolean {915
return this.settings.compaction?.enabled ?? true;916
}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
}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
}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
}955
getCompactionReserveTokens(model?: Pick<Model<string>, "provider" | "id">): number {956
return this.getCompactionTokenSetting("reserveTokens", model);957
}959
getCompactionKeepRecentTokens(model?: Pick<Model<string>, "provider" | "id">): number {960
return this.getCompactionTokenSetting("keepRecentTokens", model);961
}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
}976
getBranchSummarySettings(): { reserveTokens: number; skipPrompt: boolean } {977
return {978
reserveTokens: this.settings.branchSummary?.reserveTokens ?? 16384,979
skipPrompt: this.settings.branchSummary?.skipPrompt ?? false,980
};981
}983
getBranchSummarySkipPrompt(): boolean {984
return this.settings.branchSummary?.skipPrompt ?? false;985
}987
getRetryEnabled(): boolean {988
return this.settings.retry?.enabled ?? true;989
}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
}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
}1009
getHttpIdleTimeoutMs(): number {1010
return parseTimeoutSetting(this.settings.httpIdleTimeoutMs, "httpIdleTimeoutMs") ?? DEFAULT_HTTP_IDLE_TIMEOUT_MS;1011
}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
}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
}1028
setCacheWarmingMode(mode: CacheWarmingMode): void {1029
this.globalSettings.cacheWarming = mode;1030
this.markModified("cacheWarming");1031
this.save();1032
}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
}1042
getWebSocketConnectTimeoutMs(): number | undefined {1043
return parseTimeoutSetting(this.settings.websocketConnectTimeoutMs, "websocketConnectTimeoutMs");1044
}1046
getHideThinkingBlock(): boolean {1047
return this.settings.hideThinkingBlock ?? false;1048
}1050
getShowCacheMissNotices(): boolean {1051
return this.settings.showCacheMissNotices ?? false;1052
}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
}1066
setHideThinkingBlock(hide: boolean): void {1067
this.globalSettings.hideThinkingBlock = hide;1068
this.markModified("hideThinkingBlock");1069
this.save();1070
}1072
setShowCacheMissNotices(show: boolean): void {1073
this.globalSettings.showCacheMissNotices = show;1074
this.markModified("showCacheMissNotices");1075
this.save();1076
}1078
getShellPath(): string | undefined {1079
const shellPath = this.settings.shellPath;1080
return shellPath ? normalizePath(shellPath) : shellPath;1081
}1083
setShellPath(path: string | undefined): void {1084
this.globalSettings.shellPath = path;1085
this.markModified("shellPath");1086
this.save();1087
}1089
getQuietStartup(): QuietStartup {1090
const value = this.settings.quietStartup;1091
return value === true || value === "header" ? value : false;1092
}1094
setQuietStartup(quiet: QuietStartup): void {1095
this.globalSettings.quietStartup = quiet;1096
this.markModified("quietStartup");1097
this.save();1098
}1100
getDefaultProjectTrust(): DefaultProjectTrust {1101
const value = this.globalSettings.defaultProjectTrust;1102
return value === "always" || value === "never" ? value : "ask";1103
}1105
setDefaultProjectTrust(defaultProjectTrust: DefaultProjectTrust): void {1106
this.globalSettings.defaultProjectTrust = defaultProjectTrust;1107
this.markModified("defaultProjectTrust");1108
this.save();1109
}1111
getShellCommandPrefix(): string | undefined {1112
return this.settings.shellCommandPrefix;1113
}1115
setShellCommandPrefix(prefix: string | undefined): void {1116
this.globalSettings.shellCommandPrefix = prefix;1117
this.markModified("shellCommandPrefix");1118
this.save();1119
}1121
getNpmCommand(): string[] | undefined {1122
return this.settings.npmCommand ? [...this.settings.npmCommand] : undefined;1123
}1125
setNpmCommand(command: string[] | undefined): void {1126
this.globalSettings.npmCommand = command ? [...command] : undefined;1127
this.markModified("npmCommand");1128
this.save();1129
}1131
getCollapseChangelog(): boolean {1132
return this.settings.collapseChangelog ?? false;1133
}1135
setCollapseChangelog(collapse: boolean): void {1136
this.globalSettings.collapseChangelog = collapse;1137
this.markModified("collapseChangelog");1138
this.save();1139
}1141
getEnableInstallTelemetry(): boolean {1142
return this.settings.enableInstallTelemetry ?? true;1143
}1145
setEnableInstallTelemetry(enabled: boolean): void {1146
this.globalSettings.enableInstallTelemetry = enabled;1147
this.markModified("enableInstallTelemetry");1148
this.save();1149
}1151
getEnableAnalytics(): boolean {1152
return this.settings.enableAnalytics ?? false;1153
}1155
getTrackingId(): string | undefined {1156
return this.settings.trackingId;1157
}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
}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 project1173
* 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
}1184
getPackages(): PackageSource[] {1185
return [...(this.settings.packages ?? [])];1186
}1188
setPackages(packages: PackageSource[]): void {1189
this.globalSettings.packages = packages;1190
this.markModified("packages");1191
this.save();1192
}1194
setProjectPackages(packages: PackageSource[]): void {1195
this.updateProjectSettings("packages", (settings) => {1196
settings.packages = packages;1197
});1198
}1200
getExtensionPaths(): string[] {1201
return [...(this.settings.extensions ?? [])];1202
}1204
setExtensionPaths(paths: string[]): void {1205
this.globalSettings.extensions = paths;1206
this.markModified("extensions");1207
this.save();1208
}1210
setProjectExtensionPaths(paths: string[]): void {1211
this.updateProjectSettings("extensions", (settings) => {1212
settings.extensions = paths;1213
});1214
}1216
getSkillPaths(): string[] {1217
return [...(this.settings.skills ?? [])];1218
}1220
setSkillPaths(paths: string[]): void {1221
this.globalSettings.skills = paths;1222
this.markModified("skills");1223
this.save();1224
}1226
setProjectSkillPaths(paths: string[]): void {1227
this.updateProjectSettings("skills", (settings) => {1228
settings.skills = paths;1229
});1230
}1232
getPromptTemplatePaths(): string[] {1233
return [...(this.settings.prompts ?? [])];1234
}1236
setPromptTemplatePaths(paths: string[]): void {1237
this.globalSettings.prompts = paths;1238
this.markModified("prompts");1239
this.save();1240
}1242
setProjectPromptTemplatePaths(paths: string[]): void {1243
this.updateProjectSettings("prompts", (settings) => {1244
settings.prompts = paths;1245
});1246
}1248
getThemePaths(): string[] {1249
return [...(this.settings.themes ?? [])];1250
}1252
setThemePaths(paths: string[]): void {1253
this.globalSettings.themes = paths;1254
this.markModified("themes");1255
this.save();1256
}1258
setProjectThemePaths(paths: string[]): void {1259
this.updateProjectSettings("themes", (settings) => {1260
settings.themes = paths;1261
});1262
}1264
getEnableSkillCommands(): boolean {1265
return this.settings.enableSkillCommands ?? true;1266
}1268
setEnableSkillCommands(enabled: boolean): void {1269
this.globalSettings.enableSkillCommands = enabled;1270
this.markModified("enableSkillCommands");1271
this.save();1272
}1274
getThinkingBudgets(): ThinkingBudgetsSettings | undefined {1275
return this.settings.thinkingBudgets;1276
}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
}1288
getShowImages(): boolean {1289
return this.settings.terminal?.showImages ?? true;1290
}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
}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
}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
}1318
getClearOnShrink(): boolean {1319
// Settings takes precedence, then env var, then default false1320
if (this.settings.terminal?.clearOnShrink !== undefined) {1321
return this.settings.terminal.clearOnShrink;1322
}1323
return process.env.PI_CLEAR_ON_SHRINK === "1";1324
}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
}1335
getShowTerminalProgress(): boolean {1336
return this.settings.terminal?.showTerminalProgress ?? false;1337
}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
}1348
getTuiMode(): TuiMode {1349
return this.settings.tuiMode === "regular" ? "regular" : "fullscreen";1350
}1352
setTuiMode(mode: TuiMode): void {1353
this.globalSettings.tuiMode = mode;1354
this.markModified("tuiMode");1355
this.save();1356
}1358
getFullscreenExitOutput(): FullscreenExitOutput {1359
return this.settings.fullscreenExitOutput === "resume-hint" ? "resume-hint" : "transcript";1360
}1362
setFullscreenExitOutput(output: FullscreenExitOutput): void {1363
this.globalSettings.fullscreenExitOutput = output;1364
this.markModified("fullscreenExitOutput");1365
this.save();1366
}1368
getFullscreenScrollbar(): ScrollViewScrollbar {1369
const mode = this.settings.fullscreenScrollbar;1370
return mode === "always" || mode === "hidden" ? mode : "auto";1371
}1373
setFullscreenScrollbar(mode: ScrollViewScrollbar): void {1374
this.globalSettings.fullscreenScrollbar = mode;1375
this.markModified("fullscreenScrollbar");1376
this.save();1377
}1379
getFullscreenCopyOnSelect(): boolean {1380
return this.settings.fullscreenCopyOnSelect ?? true;1381
}1383
setFullscreenCopyOnSelect(enabled: boolean): void {1384
this.globalSettings.fullscreenCopyOnSelect = enabled;1385
this.markModified("fullscreenCopyOnSelect");1386
this.save();1387
}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
}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
}1403
getImageAutoResize(): boolean {1404
return this.settings.images?.autoResize ?? true;1405
}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
}1416
getBlockImages(): boolean {1417
return this.settings.images?.blockImages ?? false;1418
}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
}1429
getEnabledModels(): string[] | undefined {1430
return this.settings.enabledModels;1431
}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
}1440
setEnabledModels(patterns: string[] | undefined): void {1441
this.globalSettings.enabledModels = patterns;1442
this.markModified("enabledModels");1443
this.save();1444
}1446
getDoubleEscapeAction(): "fork" | "tree" | "none" {1447
return this.settings.doubleEscapeAction ?? "tree";1448
}1450
setDoubleEscapeAction(action: "fork" | "tree" | "none"): void {1451
this.globalSettings.doubleEscapeAction = action;1452
this.markModified("doubleEscapeAction");1453
this.save();1454
}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
}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
}1468
getShowHardwareCursor(): boolean {1469
return this.settings.showHardwareCursor ?? process.env.PI_HARDWARE_CURSOR === "1";1470
}1472
setShowHardwareCursor(enabled: boolean): void {1473
this.globalSettings.showHardwareCursor = enabled;1474
this.markModified("showHardwareCursor");1475
this.save();1476
}1478
getEditorPaddingX(): number {1479
return this.settings.editorPaddingX ?? 0;1480
}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
}1488
getOutputPad(): 0 | 1 {1489
return this.settings.outputPad === 0 ? 0 : 1;1490
}1492
setOutputPad(padding: 0 | 1): void {1493
this.globalSettings.outputPad = padding;1494
this.markModified("outputPad");1495
this.save();1496
}1498
getAutocompleteMaxVisible(): number {1499
return this.settings.autocompleteMaxVisible ?? 5;1500
}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
}1508
getCodeBlockIndent(): string {1509
return this.settings.markdown?.codeBlockIndent ?? " ";1510
}1512
getMermaidRenderingMode(): MermaidRenderingMode {1513
const mode = this.settings.markdown?.mermaid;1514
return mode === "off" || mode === "final" ? mode : "streaming";1515
}1517
setMermaidRenderingMode(mode: MermaidRenderingMode): void {1518
this.globalSettings.markdown ??= {};1519
this.globalSettings.markdown.mermaid = mode;1520
this.markModified("markdown", "mermaid");1521
this.save();1522
}1524
getWarnings(): WarningSettings {1525
return { ...(this.settings.warnings ?? {}) };1526
}1528
setWarnings(warnings: WarningSettings): void {1529
this.globalSettings.warnings = { ...warnings };1530
this.markModified("warnings");1531
this.save();1532
}1533
}